From 9a52ba7ee1c483158314e3a4aba5550b59736663 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 15:28:45 +0800 Subject: [PATCH 01/51] =?UTF-8?q?feat(tavern):=20=E5=90=88=E5=85=A5=20Pro?= =?UTF-8?q?=20=E7=9A=84=20P2=20=E7=BE=A4=E8=81=8A=EF=BC=88125-r2=20+=20125?= =?UTF-8?q?-r3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正式包 P2-group125-r2-final-patch.zip 与 P2-group125-r3-final-patch.zip:原始层 P2-group125-r2-on120123.patch(4cfa0d6c…)+ P2-group125-r3-on125-r2.patch(2eeeb698…), 在原始层链 …→123-r2 上 apply(与 Pro 的 checkpoint-raw125r3 包域树相同),format 后与 主线比出 17 个文件。 共用一棵对话树的群聊:建群、成员(发言积极度、静音、主/辅模型)、自然/列表/随机/ 手动/导演五种排程,每批最多 3 人依次流式;每条消息带 speakerCharacterId;主持卡管 共享状态、故事和导演;所有成员(含静音的)的卡内书都参与。schema 升到 7。 125-r3:JSONL 只在有发言者时写 speakerCharacterId(原先给 user 节点也写 null)。 引擎 analyze 无问题,全包 2550 过 / 6 跳。 --- packages/dsh_tavern/README.md | 74 ++ packages/dsh_tavern/lib/src/api.dart | 61 +- packages/dsh_tavern/lib/src/engine.dart | 625 +++++++--- .../dsh_tavern/lib/src/engine_director.dart | 38 +- packages/dsh_tavern/lib/src/engine_group.dart | 921 ++++++++++++++ .../dsh_tavern/lib/src/engine_secrets.dart | 2 +- packages/dsh_tavern/lib/src/engine_state.dart | 4 +- packages/dsh_tavern/lib/src/group.dart | 150 +++ .../dsh_tavern/lib/src/io/chat_jsonl.dart | 16 +- .../dsh_tavern/lib/src/store/chat_store.dart | 156 ++- packages/dsh_tavern/test/chat_jsonl_test.dart | 79 ++ .../test/p2_group_store_engine_test.dart | 609 ++++++++++ .../test/p2_group_turn_engine_test.dart | 1075 +++++++++++++++++ .../test/t12a_director_store_test.dart | 82 +- .../test/t17_agent_runs_store_test.dart | 2 +- .../test/t18_director_store_test.dart | 4 +- .../test/t20_schema_migration_test.dart | 13 +- 17 files changed, 3683 insertions(+), 228 deletions(-) create mode 100644 packages/dsh_tavern/lib/src/engine_group.dart create mode 100644 packages/dsh_tavern/lib/src/group.dart create mode 100644 packages/dsh_tavern/test/p2_group_store_engine_test.dart create mode 100644 packages/dsh_tavern/test/p2_group_turn_engine_test.dart diff --git a/packages/dsh_tavern/README.md b/packages/dsh_tavern/README.md index 6ba76db125..f5d440bb47 100644 --- a/packages/dsh_tavern/README.md +++ b/packages/dsh_tavern/README.md @@ -1003,3 +1003,77 @@ createChat 将选中的 first_mes 或 alternate_greetings 原文直接交给 AI_ 本包仍按原合同执行 AI_OUTPUT 正则→整段宏展开→去 CR,并保留局部状态、SQL 事务和已发生全局效果的失败处理。公开 greeting 索引仍是 0 对应 first_mes、其后按原 alternate 顺序计数;空字符串占位,选择空项仍保存空正文根,greetingIndex 保留实际索引,不模拟 ST 界面在空 first_mes 时重排默认项的行为。 新增两个公开门面回归使用真实文件/SQLite、字段编辑、JSONL 导出与一次重开,不调用模型;第二项只检验本包既有处理次序,不声称执行过 ST 整体创建与宏展开流程。实际 Dart 结果及唯一核心的旧产品差异仍待本机验证。 + +# P2 G1–G3:共用聊天树的群聊 + +本层实现已交付 `P2-API.md` 的群聊 A 部分。每批使用同一聊天树,演员按有限计划逐个完成;下一人读取前一人已经提交的正文、普通变量和主持状态。没有循环调用公共 `send`,也没有并行演员请求。这里描述候选源码行为;本地未运行 Dart、formatter 或测试,实际验证结果以本层代跑回件为准。 + +## 建群、成员与旧入口 + +```dart +final groupId = await tavern.createGroupChat( + [aliceId, bobId], + hostCharacterId: hostId, + activation: TavernGroupActivation.listOrder, + maxSpeakersPerTurn: 3, + addGreetings: false, +); +await tavern.updateGroupMember(groupId, bobId, + talkativeness: 0.5, modelRole: TavernGroupModelRole.helper); +final events = tavern.sendGroup(groupId, '一起讨论下一步。'); +// 明确让某一成员说一次;允许覆盖该成员的 mute。 +final oneSpeaker = tavern.sendGroup(groupId, '', speakerCharacterId: bobId); +``` + +两个返回的 stream 都在监听时接纳,示例中的调用本身不会启动模型;同一聊天只能监听接纳一个主操作,另一个会得到 busy。`groupChat` / `watchGroupChat` 返回不可变的成员快照;`addGroupMember`、`updateGroupMember`、`removeGroupMember`、`reorderGroupMembers` 和 `updateGroupChat` 返回更新后的完整配置。调用时复制成员列表,排队后不会重读调用方的可变 List。 + +主持必须是真实库卡,缺省为首成员,可以不在发言名单;有显式主持可建空名单,名单移空仍是群聊。主持定义来源首版不可隐式重绑。`deleteCharacter` 若仍被任何群用作主持,返回 `TavernGroupException(hostInUse)` 和 `relatedChatIds`,在库队列实际删除前再次检查;移除主持的发言席位不改变这个引用。删除非主持卡先取消排空受影响批次,再移出名单,保留群历史和旧发言 ID。文件删除与 SQL 不承诺跨资源原子;文件已失而 SQL 未完成时读取可显示 `available=false`,不会因此删除群。 + +成员 ID 必须存在且不重复。talkativeness 的显式输入必须是有限 0..1;缺省从卡读取并规范化,非法或缺失用 0.5,随后作为群偏好独立保存。update 的 null 表示不改;重排必须是完整排列;上限为正整数。CRUD 校验经 Future 返回 `TavernGroupException` 的领域 code,真实 I/O 错误保留存储失败。无群或单聊的 `groupChat` 为 null。 + +| 入口 | 群行为 | +| --- | --- | +| `sendGroup` / `resumeGroupTurn` | 新群事件流;明确点名只排一人,重连只给当前快照和后续事件 | +| `send` | 空闲群为 invalidParameters / `group_requires_sendGroup`;已有同聊天主操作时先返回 busy | +| `resumeTurn` | 活跃群批次为 `group_requires_resumeGroupTurn`;群内单次重做仍用原入口观察 | +| `regenerate` / `continueReply` | 从目标 `speakerCharacterId` 选当前在册演员及模型角色,只生成一人;未知身份、移出名单或缺失卡明确失败,静音不阻止显式重做 | +| `impersonate` | 空闲群为 invalidParameters / `group_impersonate_unsupported` | +| `sendOnly` / `narrate` | 取消并排空整个批次,再用主持环境写一条,无模型调用 | +| `stop` / 树结构编辑 / 成员配置编辑 | stop 取消整批;结构编辑先取消排空,之后才修改,不等待暂停的 observer | + +## 调度、流与费用 + +- natural 采用固定 ST 的 ASCII 单词点名、shuffle、talkativeness 阈值、fallback、去重,然后应用本项目 cap。无输入时只看实际最后一行;该行 is_system 为真则正文为空,不倒查更早消息。`allowSelfResponses=false` 的 banned 影响原前两段,但原 fallback 仍可能选回自己。中文明确点名使用 ID。 +- listOrder 是按当前名单从头列出启用成员再 cap,不保存公平轮转游标。randomPool 每次只选一人;新用户输入清空“本轮已说过”池,否则从最近用户行之后的真实 speaker ID 计算池。 +- manual 未明确点名就不生成;有输入仍保存用户行并结束 noSpeakers。这与固定 ST MANUAL 的无输入随机回退是明示差异。director 只读本轮捕获的合格已保存提案,不临时调用导演补人。 +- mute 只禁止自动选人;零 talkativeness 仍可能因原阈值或 fallback 被选中。读取 DTO、重连和 UI 展示均不重抽随机。 + +正常顺序为外层 `TavernGroupStarted` → ready → speaking(`TavernGroupMemberEvent` 包住 Started/delta)→ SQL 提交后的 settling → 内层 Done → 下一演员 → 唯一 `TavernGroupFinished`。内层 Failed 直接进入外层 failed/cancelled 终态;settling 只标明演员 SQL 已提交,不代表整批成功。`remainingCharacterIds` 只包含尚未开始者,`speakerCharacterId` 是当前演员;`generationId` 在该演员活跃时存在,结束后清空。每份 progress 的列表是独立不可变副本。每个 child 仍只有一个原 TavernEvent 终态,不能把 child Done 当成整群完成。 + +取消订阅只离页。`resumeGroupTurn` 原子注册并发送 `TavernGroupSnapshot`(含尚未 checkpoint 的完整当前正文),不重放 delta、RNG、Key 或 HTTP;无活跃批次就关流。stop、close 或进程终止后不自动补剩余演员。外层占用覆盖准备、演员间隙和 settling;人设、预设删除也识别整批捕获的资源。 + +用户行最多提交一次;提交后 `progress.userNodeId` 即凭据,第一演员零输出失败也不删除它。A 成功、B 失败时 A 保留;B 有输出按原规则保存 partial,零输出新回复撤回,C 不启动。失败终态的 remaining 保留未启动名单,仅用于解释。终态存储失败明确为 server,不保证 partial 是完整最新文本,不应据此自动再插用户行。 + +每名演员是独立请求和用量。main 使用本批捕获的模型配置;helper 在 DeepSeek 用 `deepseek-flash` / thinking=false,其余 provider 用原 model / thinking=false,沿同一个 endpoint 和 Key callback。helper 演员仍走普通角色预设及流式通道,不携带导演 direct 工具。取消后不额外启动下一人的 Key/HTTP。 + +## 演员、主持和所有成员的书 + +演员决定人物字段、`char`、角色正则、当前 WI 过滤和发言身份;真实主持独占共享 DSH schema/规则、故事、秘密和导演定义。不会把演员的另一套 schema 反复初始化进共享状态。创建时主持初值保存一次;无 greeting/空名单也用 `group_chats.initial_state` 保存同一根基线,空路径 readState、首演员、重开和完整备份都读取它。群开场按名单选择、展开并写入真实演员 ID;添加成员不自动补开场。 + +提示词采用固定 ST SWAP:当前演员字段加完整共享可见历史,传入现有 assembler 的 groupNames、群开场/nudge/姓名处理及原预算。`group` 为全部可用成员名、`groupNotMuted` 为未静音可用成员名,主持不在名单就不偷偷加入。输入宏/写回正则只执行一次,有计划用首演员环境,无计划用主持。角色 prompt/写回正则仍仅用当前演员;显示/编辑有 ID 的旧正文用真实发言卡,缺失或无身份的历史可继续显示,显示结果以 `group_speaker_unavailable` 标记未套角色规则,不按同名猜卡。 + +每名演员都加载在册成员(含静音者)的嵌入书,以及每个成员显式 characterLorebooks 的并集;共享 global/chat/persona/memory 绑定保留。外部书维持 bookId,并由现有 `_sortedEntries` 按原 global→chat→persona→character→memory 优先级去重;嵌入书保持 `:card`。当前演员仍决定 characterFilter/tags。主持不在名单时不因主持身份额外注入主持内嵌书。全员书是 ADDENDUM-10 的产品要求,已读固定 ST getCharacterLore 本身仅证明当前 chId 的读取,不冒称原 ST 所有模式已有全员书。 + +共用历史不新增跨角色思考传输。只有当前演员、同模型及 provider 来源的已知群消息可沿原 reasoning 设置参加;来源未知或其他演员的 reasoning 不新增到正文。每位演员正常提交各结算一次 T17,因此最多三人可能消耗三份 x-dsh-step 额度;章节回合计数及普通自动导演只在完整批次最后结算一次。失败/取消/无人发言不额外推进故事或安排新自动导演,已有演员的状态修改保留。既有秘密增强若显式开启,仍可能按原合同另安排一份秘密 helper。 + +导演点名只接受已保存 `name='group.speaker'`、字符串 data 内 `{ "characterId": "真实ID" }` 的提案。验证原成功来源、祖先/fingerprint、稳定 eventId 和原记录字段;群导演作业即使没有卡 eventRules 也持久保存 outputIndex,但不因此新增 eventCapability。不按名字猜、不扩展其他事件执行权限。无效、静音、重复或不可用候选保存 `group.selection` 审计,`readDirector().issues` 与演员 inspection 保留诊断。实际消耗的 eventId 写到回复节点的分支局部 extra;零输出撤回不消费,partial 留存则消费,其他未消费分支可按原来源规则再次选择。原 pending proposal 不被全局删除或改写。 + +## 持久化和 App 接合 + +SQLite 注册迁移升至 7:group_chats、group_members、nodes.speaker_character_id;主持仍是 chats.character_id。旧单聊不变群,旧节点未知 ID 保持 null,新开场和生成填写已知 ID。聊天列表/角色统计同时识别主持和成员且每 chat 去重;消息/path/tree/search/流/终态及复制均透传 ID。JSONL 使用 `extra.dshgo.chatJsonl.speakerCharacterId` 保留每个 swipe 身份;本地 UUID 不能冒充 ST original_avatar。完整备份包含群表、初始状态和消息 ID;不宣称目前只有 JSONL 的接口能够完整互导 ST groups 定义,不能按同名自动映射跨库成员。 + +App 的 `DeferredTavern` 要转发所有新群方法,Preview 由 owner 按完整群事件序列模拟。本层没有读取或修改 App。`TavernChatSummary` 增加 kind 与不可变 memberCharacterIds;为复制列表,构造器不再 const,既有 `const TavernChatSummary(...)` 调用应移除 const。其 characterId 在群里表示主持,消息演员应读 nullable speakerCharacterId。新 DTO 字段均有单聊默认值;不把主持头像当成每条消息的发言人。 + +本层未实现 P2 B 的脚本注册、JS 宿主或 MVU:settling/generationId 是真实提交边界和后续接合点,当前不会制造成功 hook Ack。B 接入时必须在 child SQL 已提交之后完成宿主同步/handler 写确认,再发 Done;同 hook 目标变量短写不能排队等待自己的 outer.done,外部结构变化则取消剩余。批尾 story 当前复用已提交 child 的私有缓冲;B 若允许 hook 编辑正文/变量,必须先刷新或仅提交最新运行态,不能用旧缓冲覆盖 hook 写入。本层没有声称该未来 hook 合同已实测。 + +固定参考为 ST `7e8663cd9c184a550b37238218bdd32c6efc68e9` 的 [group-chats.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/group-chats.js)、[utils.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/utils.js)、[openai.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/openai.js) 及 [world-info.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/world-info.js)。这次只读源码,没有新增 ST probe;`p2_group_store_engine_test.dart`、`p2_group_turn_engine_test.dart` 和旧 schema 回归的实际运行由正式代跑给证据,不把静态源码审阅当作通过。 diff --git a/packages/dsh_tavern/lib/src/api.dart b/packages/dsh_tavern/lib/src/api.dart index 9cac419374..ca24335a3c 100644 --- a/packages/dsh_tavern/lib/src/api.dart +++ b/packages/dsh_tavern/lib/src/api.dart @@ -19,6 +19,8 @@ import 'regex_library.dart'; import 'state.dart'; import 'worldinfo/world_info.dart' show WorldInfoSource; +part 'group.dart'; + enum PresetSelectionSource { chat, chatInline, globalDefault, globalInline, stDefault } /// Selection for a newly admitted turn. Missing legacy ids fall through to @@ -121,14 +123,16 @@ class TavernCharacterAsset { /// A conversation in the list. class TavernChatSummary { - const TavernChatSummary({ + TavernChatSummary({ required this.id, required this.characterId, required this.title, required this.updatedAt, this.lastText, this.messageCount = 0, - }); + this.kind = TavernChatKind.single, + List memberCharacterIds = const [], + }) : memberCharacterIds = List.unmodifiable(memberCharacterIds); final String id; final String characterId; @@ -138,6 +142,8 @@ class TavernChatSummary { /// The last message on the current path, plain text, for a preview line. final String? lastText; final int messageCount; + final TavernChatKind kind; + final List memberCharacterIds; } enum TavernRole { user, character, narrator, system } @@ -158,6 +164,7 @@ class TavernMessage { this.hidden = false, this.usage, this.reportedUsage, + this.speakerCharacterId, }); final String nodeId; @@ -167,6 +174,9 @@ class TavernMessage { /// Display name: the character's or the persona's. final String speaker; + /// Stable library identity; null for users, narration and unknown imports. + final String? speakerCharacterId; + /// The stored text (after write-back regex). Use displayText for an ephemeral /// display-regex copy; Markdown rendering remains the caller's responsibility. final String text; @@ -202,6 +212,7 @@ class TavernTreeNode { this.siblingCount = 1, this.hidden = false, this.branchName, + this.speakerCharacterId, }); final String nodeId; @@ -223,6 +234,7 @@ class TavernTreeNode { /// An optional label for the branch starting at this node. final String? branchName; + final String? speakerCharacterId; } /// What one model call cost, as the API reported it. @@ -495,6 +507,51 @@ abstract interface class Tavern { /// A new chat whose first message is greeting [greeting] (0 = `first_mes`). Future createChat(String characterId, {int greeting = 0}); + /// A shared tree with a stable host definition and sequential actor calls. + /// Mutable inputs are captured at invocation. See [sendGroup] for admission. + Future createGroupChat( + List characterIds, { + String? hostCharacterId, + String? title, + TavernGroupActivation activation = TavernGroupActivation.natural, + int maxSpeakersPerTurn = 3, + bool allowSelfResponses = false, + bool addGreetings = true, + }); + Future groupChat(String chatId); + Stream watchGroupChat(String chatId); + Future addGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool muted = false, + TavernGroupModelRole? modelRole, + }); + Future updateGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool? muted, + TavernGroupModelRole? modelRole, + }); + Future removeGroupMember(String chatId, String characterId); + Future reorderGroupMembers(String chatId, List characterIds); + Future updateGroupChat( + String chatId, { + String? title, + TavernGroupActivation? activation, + int? maxSpeakersPerTurn, + bool? allowSelfResponses, + }); + + /// Listening admits one batch. Subscription cancellation only detaches. + /// An explicit member overrides selection (including mute) for one reply. + /// A committed user line survives failure; progress.userNodeId identifies it. + Stream sendGroup(String chatId, String text, {String? speakerCharacterId}); + + /// Atomic snapshot plus future batch events; never starts a model request. + Stream resumeGroupTurn(String chatId); + /// Imports a same-character batch without adding greetings or evaluating /// macros. Related main_chat files become branches; unrelated files remain /// separate chats. A malformed document rejects the whole batch. diff --git a/packages/dsh_tavern/lib/src/engine.dart b/packages/dsh_tavern/lib/src/engine.dart index 9e5c0fc55f..afdf21db93 100644 --- a/packages/dsh_tavern/lib/src/engine.dart +++ b/packages/dsh_tavern/lib/src/engine.dart @@ -52,6 +52,7 @@ part 'engine_director_watch.dart'; part 'engine_story.dart'; part 'engine_card_events.dart'; part 'engine_secrets.dart'; +part 'engine_group.dart'; /// App convenience factory. [directory] is the actual tavern data directory, /// normally `/tavern`. No extra path component is appended. @@ -135,7 +136,7 @@ class TavernEngine implements Tavern { if (_closed) return; _paths.add(chatId); _directors.changed(chatId); - if (!_turns.containsKey(chatId)) _notifyLists(); + if (!_mainBusy(chatId)) _notifyLists(); }); _personaSubscription = _personas.changes.listen((_) => _directors.changedAll()); } @@ -249,6 +250,8 @@ class TavernEngine implements Tavern { final _chatsChanged = StreamController.broadcast(); final _paths = StreamController.broadcast(); final _turns = {}; + late final _GroupCoordinator _groups = _GroupCoordinator(this); + bool _mainBusy(String chatId) => _turns.containsKey(chatId) || _groups.rounds.containsKey(chatId); final _mutations = >{}; final _deletingCharacters = {}; final _deletingPersonas = {}; @@ -381,6 +384,8 @@ class TavernEngine implements Tavern { updatedAt: chat.updatedAt, lastText: chat.lastText == null ? null : _plainText(chat.lastText!), messageCount: chat.messageCount, + kind: chat.kind, + memberCharacterIds: chat.memberCharacterIds, ), ]; @@ -689,19 +694,32 @@ class TavernEngine implements Tavern { @override Future deleteCharacter(String characterId) { _ensureAdmission(); + final hosted = _store.groupHostChats(characterId); + if (hosted.isNotEmpty) { + return Future.error( + TavernGroupException(TavernGroupError.hostInUse, characterId: characterId, relatedChatIds: hosted), + ); + } if (!_deletingCharacters.add(characterId)) { return Future.error(StateError('Character deletion is already running.')); } return _operation( () => _libraryOperation(() async { try { + final hosted = _store.groupHostChats(characterId); + if (hosted.isNotEmpty) { + throw TavernGroupException(TavernGroupError.hostInUse, characterId: characterId, relatedChatIds: hosted); + } final ids = _store.listChats(characterId: characterId).map((chat) => chat.id).toList(); await Future.wait([for (final id in ids) _withMutation(id, () {})]); await Future.wait([for (final id in ids) _directors.cancelChat(id)]); await _library.delete(characterId); _characterCache = null; unawaited(_characterDetails.remove(characterId)); - _store.deleteCharacterChats(characterId); + _store.transaction(() { + _store.removeCharacterGroupMemberships(characterId); + _store.deleteCharacterChats(characterId); + }); for (final id in ids) { _inspections.remove(id); _latestInspections.remove(id); @@ -793,7 +811,8 @@ class TavernEngine implements Tavern { for (final chat in _store.listChatPersonaBindings()) { if (chat.personaId == personaId || _personas.resolve(personaId: chat.personaId).persona?.id == personaId || - _turns[chat.id]?.personaSelection.persona?.id == personaId) { + _turns[chat.id]?.personaSelection.persona?.id == personaId || + _groups.rounds[chat.id]?.persona.persona?.id == personaId) { affected.add(chat.id); } } @@ -865,6 +884,7 @@ class TavernEngine implements Tavern { parentId: null, role: TavernRole.character, speaker: record.card.promptName, + speakerCharacterId: characterId, text: text, state: state, ); @@ -895,6 +915,98 @@ class TavernEngine implements Tavern { }), ); + @override + Future createGroupChat( + List characterIds, { + String? hostCharacterId, + String? title, + TavernGroupActivation activation = TavernGroupActivation.natural, + int maxSpeakersPerTurn = 3, + bool allowSelfResponses = false, + bool addGreetings = true, + }) => _operation(() { + final ids = List.of(characterIds); + return _libraryOperation( + () => _groups.create( + ids, + hostCharacterId: hostCharacterId, + title: title, + activation: activation, + maxSpeakersPerTurn: maxSpeakersPerTurn, + allowSelfResponses: allowSelfResponses, + addGreetings: addGreetings, + ), + ); + }); + @override + Future groupChat(String chatId) => _operation(() => _groups.read(chatId)); + @override + Stream watchGroupChat(String chatId) { + _ensureOpen(); + return _watch(_chatsChanged.stream, () => _groups.read(chatId)); + } + + @override + Future addGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool muted = false, + TavernGroupModelRole? modelRole, + }) => _operation( + () => _libraryOperation( + () => _withMutation( + chatId, + () => _groups.add(chatId, characterId, talkativeness: talkativeness, muted: muted, modelRole: modelRole), + ), + ), + ); + @override + Future updateGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool? muted, + TavernGroupModelRole? modelRole, + }) => _operation( + () => _withMutation( + chatId, + () => _groups.updateMember(chatId, characterId, talkativeness: talkativeness, muted: muted, modelRole: modelRole), + ), + ); + @override + Future removeGroupMember(String chatId, String characterId) => + _operation(() => _withMutation(chatId, () => _groups.remove(chatId, characterId))); + @override + Future reorderGroupMembers(String chatId, List characterIds) => _operation(() { + final captured = List.of(characterIds); + return _withMutation(chatId, () => _groups.reorder(chatId, captured)); + }); + @override + Future updateGroupChat( + String chatId, { + String? title, + TavernGroupActivation? activation, + int? maxSpeakersPerTurn, + bool? allowSelfResponses, + }) => _operation( + () => _withMutation( + chatId, + () => _groups.update( + chatId, + title: title, + activation: activation, + maxSpeakersPerTurn: maxSpeakersPerTurn, + allowSelfResponses: allowSelfResponses, + ), + ), + ); + @override + Stream sendGroup(String chatId, String text, {String? speakerCharacterId}) => + _groups.send(chatId, text, speakerCharacterId: speakerCharacterId); + @override + Stream resumeGroupTurn(String chatId) => _groups.resume(chatId); + @override Future importChats( String characterId, @@ -1007,6 +1119,7 @@ class TavernEngine implements Tavern { siblingCount: message.siblingCount, hidden: message.hidden, branchName: name is String ? name : null, + speakerCharacterId: message.speakerCharacterId, ), ); } @@ -1361,7 +1474,8 @@ class TavernEngine implements Tavern { final chat = _store.chat(summary.id); if (chat.presetId == presetId || _resolvePreset(chat).selection.effectivePresetId == presetId || - _turns[chat.id]?.presetId == presetId) { + _turns[chat.id]?.presetId == presetId || + _groups.rounds[chat.id]?.presetId == presetId) { affected.add(chat.id); } } @@ -1661,9 +1775,16 @@ class TavernEngine implements Tavern { final settings = _personaSettings(_effectiveSettings(chat, metadata), _personas.resolve(personaId: chat.personaId)); final config = _config; final now = _clock(); - final record = await _record(chat.characterId); + final group = _store.groupChat(chat.id); + final displayNode = job.nodeId == null ? null : _store.node(chat.id, job.nodeId!); + final actorId = group != null && displayNode?.message.role == TavernRole.character + ? displayNode!.message.speakerCharacterId + : active?.actorId ?? chat.characterId; + final record = actorId == null ? null : await _record(actorId); + final groupView = group == null ? null : await _groups.capture(group); if (!current()) return TavernDisplayResult(text: job.text, superseded: true); - if (record == null) throw const TavernImportError('角色不存在。'); + if (record == null && group == null) throw const TavernImportError('角色不存在。'); + final data = record?.card.data ?? {'name': displayNode?.message.speaker ?? ''}; final contextTarget = job.nodeId ?? active?.reply?.nodeId; if (!job.reasoning && contextTarget != null) { final index = history.indexWhere((node) => node.message.nodeId == contextTarget); @@ -1671,7 +1792,7 @@ class TavernEngine implements Tavern { } final random = Random(_displaySeed('${job.chatId}\u0000${job.nodeId ?? ''}\u0000${job.reasoning}')); final environment = _MacroEnvironment( - _effectiveCard(record.card.data, settings), + _effectiveCard(data, settings), settings, local, global, @@ -1684,7 +1805,9 @@ class TavernEngine implements Tavern { active?.generationType ?? 'normal', chatIdHash: _finiteNumber(metadata['chat_id_hash']), pickRerollSeed: metadata['pick_reroll_seed'], - characterId: chat.characterId, + characterId: actorId ?? '', + groupNames: groupView?.names, + groupNotMuted: groupView?.enabledNames, ); final placement = job.reasoning ? RegexPlacement.reasoning @@ -1710,7 +1833,17 @@ class TavernEngine implements Tavern { ); return TavernDisplayResult( text: rendered.text, - warnings: rendered.warnings, + warnings: [ + ...rendered.warnings, + if (group != null && record == null) + const RegexDisplayWarning( + ruleId: '', + ruleName: '', + ruleIndex: -1, + reason: 'group_speaker_unavailable', + message: 'The historical speaker identity is unavailable; character regex was omitted.', + ), + ], replacements: rendered.replacements, superseded: rendered.cancelled || !current(), ); @@ -2306,11 +2439,17 @@ class TavernEngine implements Tavern { // this same gate; none can enter while another affected chat is draining. for (final id in ids) { _mutations[id] = released.future; + _groups.cancel(id); _turns[id]?.cancellation.cancel(); } return () async { try { await Future.wait(previous); + final groups = [for (final id in ids) ?_groups.rounds[id]]; + for (final group in groups) { + group.cancel(); + } + await Future.wait([for (final group in groups) group.done.future]); final running = ids.map((id) => _turns[id]).whereType<_Turn>().toList(); for (final turn in running) { turn.cancellation.cancel(); @@ -2381,9 +2520,15 @@ class TavernEngine implements Tavern { Future edit(String chatId, String nodeId, String text) => _operation( () => _withMutation(chatId, () async { final chat = _store.chat(chatId); - final record = await _record(chat.characterId); - if (record == null) throw const TavernImportError('角色不存在。'); final target = _store.node(chatId, nodeId); + final group = _store.groupChat(chatId); + final actorId = group != null && target.message.role == TavernRole.character + ? target.message.speakerCharacterId + : chat.characterId; + final record = actorId == null ? null : await _record(actorId); + if (record == null && group == null) throw const TavernImportError('角色不存在。'); + final groupView = group == null ? null : await _groups.capture(group); + final data = record?.card.data ?? {'name': target.message.speaker}; final current = _historyTo(chatId, chat.currentLeafId).any((node) => node.message.nodeId == nodeId); var terminal = current ? _store.node(chatId, chat.currentLeafId!) : target; while (!current && terminal.selectedChildId != null) { @@ -2401,7 +2546,7 @@ class TavernEngine implements Tavern { _personas.resolve(personaId: chat.personaId), ); final environment = _MacroEnvironment( - _effectiveCard(record.card.data, settings), + _effectiveCard(data, settings), settings, local, _globalVariables, @@ -2414,7 +2559,9 @@ class TavernEngine implements Tavern { 'normal', chatIdHash: _finiteNumber(metadata['chat_id_hash']), pickRerollSeed: metadata['pick_reroll_seed'], - characterId: chat.characterId, + characterId: actorId ?? '', + groupNames: groupView?.names, + groupNotMuted: groupView?.enabledNames, ); final globals = _runtimeCopy(_globalVariables); try { @@ -2591,7 +2738,7 @@ class TavernEngine implements Tavern { } Map _stateAt(String chatId, String? nodeId) { - if (nodeId == null) return {}; + if (nodeId == null) return _store.groupInitialState(chatId); final node = _store.node(chatId, nodeId); final override = _map(node.extra['dshgo'])['tavernState']; final state = override is Map ? _copy(override) : _store.nodeState(chatId, nodeId); @@ -2622,126 +2769,150 @@ class TavernEngine implements Tavern { _operation(() => _appendManualMessage(chatId, text, narrator: true, speaker: speaker)); Future _appendManualMessage(String chatId, String text, {required bool narrator, String? speaker}) => - _withMutation(chatId, () async { - final chat = _store.chat(chatId); - final record = await _record(chat.characterId); - if (record == null) { - throw const TavernImportError('角色不存在。'); - } - final leaf = chat.currentLeafId; - final metadata = _chatCodec.metadataForNode(_store, chatId); - final state = leaf == null ? _emptyChatState(metadata) : _stateAt(chatId, leaf); - final source = state['chatJsonlSourceFile'] ?? _map(_map(chat.metadata['dshgo'])['chatJsonl'])['rootFile']; - final sourceFile = source is String ? source : null; - if (sourceFile != null) { - state['chatJsonlSourceFile'] = sourceFile; - } - final cached = _liveVariables[chatId]; - final local = cached != null && cached.leaf == leaf - ? _runtimeCopy(cached.values) - : state.containsKey('variables') - ? _map(state['variables']) - : leaf != null && sourceFile != null - ? {} - : _copy(chat.variables); - final settings = _personaSettings( - _effectiveSettings(chat, metadata), - _personas.resolve(personaId: chat.personaId), - ); - final history = _historyTo(chatId, leaf); - final presetRaw = _map(settings['preset']); - final preset = ChatPreset({ - ...(presetRaw.isEmpty ? ChatPreset.stDefault().raw : presetRaw), - 'show_thoughts': _config.thinking, - }); - final environment = _MacroEnvironment( - _effectiveCard(record.card.data, settings), - settings, - local, - _globalVariables, - history, - _config, - _clock(), - _random, - _macroChatIdentity(chat, state, metadata, sourceFile, node: history.lastOrNull), - text, - 'normal', - chatIdHash: _finiteNumber(metadata['chat_id_hash']), - pickRerollSeed: metadata['pick_reroll_seed'], - characterId: chat.characterId, - preset: preset, - ); - final beforeGlobals = _jsonString(_globalVariables); - late final String expanded; - try { - // Adapted from SillyTavern (AGPL-3.0), commit - // 7e8663cd9c184a550b37238218bdd32c6efc68e9: - // public/script.js::sendMessageAsUser and - // public/scripts/slash-commands.js::sendNarratorMessage. - // sendMessageAsUser applies USER_INPUT before substitution. - // sendNarratorMessage instead substitutes text.trim(), without regex 3. - final prepared = narrator - ? trimJsWhitespace(text) - : environment.regex.apply(text, placement: RegexPlacement.userInput, expand: environment.regexExpand); - expanded = environment.expand(prepared, const {}); - } catch (prepareError, prepareStackTrace) { - // Local state is detached, but preparation uses ST/T11's real globals. - // Preserve both failures when those effects cannot be saved either. - try { - if (beforeGlobals != _jsonString(_globalVariables)) { - await _saveDocument(); - } - } catch (saveError, saveStackTrace) { - Error.throwWithStackTrace( - _PreparationSaveFailure( - prepareError, - prepareStackTrace, - saveError, - saveStackTrace, - operation: narrator ? 'narrate' : 'sendOnly', - ), - saveStackTrace, - ); - } - Error.throwWithStackTrace(prepareError, prepareStackTrace); - } - // Save preparation effects before SQL: a failed write must not append a - // manual message. Later SQL failure rolls back local state and the tree, - // while already saved global preparation effects remain committed. + _withMutation(chatId, () => _appendMessage(chatId, text, narrator: narrator, speaker: speaker)); + + Future _appendMessage( + String chatId, + String text, { + required bool narrator, + String? speaker, + _GroupView? groupView, + StoredCharacter? actor, + Map? settingsOverride, + TavernModelConfig? configOverride, + ModelCancellation? cancellation, + }) async { + final chat = _store.chat(chatId); + if (groupView == null) { + final group = _store.groupChat(chatId); + if (group != null) groupView = await _groups.capture(group); + } + final record = actor ?? groupView?.host ?? await _record(chat.characterId); + if (record == null) { + throw const TavernImportError('角色不存在。'); + } + final leaf = chat.currentLeafId; + final metadata = _chatCodec.metadataForNode(_store, chatId); + final state = leaf == null && groupView == null ? _emptyChatState(metadata) : _stateAt(chatId, leaf); + final source = state['chatJsonlSourceFile'] ?? _map(_map(chat.metadata['dshgo'])['chatJsonl'])['rootFile']; + final sourceFile = source is String ? source : null; + if (sourceFile != null) { + state['chatJsonlSourceFile'] = sourceFile; + } + final cached = _liveVariables[chatId]; + final local = cached != null && cached.leaf == leaf + ? _runtimeCopy(cached.values) + : state.containsKey('variables') + ? _map(state['variables']) + : leaf != null && sourceFile != null + ? {} + : _copy(chat.variables); + final settings = + settingsOverride ?? + _personaSettings(_effectiveSettings(chat, metadata), _personas.resolve(personaId: chat.personaId)); + final config = configOverride ?? _config; + cancellation?.check(); + final history = _historyTo(chatId, leaf); + final presetRaw = _map(settings['preset']); + final preset = ChatPreset({ + ...(presetRaw.isEmpty ? ChatPreset.stDefault().raw : presetRaw), + 'show_thoughts': config.thinking, + }); + final environment = _MacroEnvironment( + _effectiveCard(record.card.data, settings), + settings, + local, + _globalVariables, + history, + config, + _clock(), + _random, + _macroChatIdentity(chat, state, metadata, sourceFile, node: history.lastOrNull), + text, + 'normal', + chatIdHash: _finiteNumber(metadata['chat_id_hash']), + pickRerollSeed: metadata['pick_reroll_seed'], + characterId: record.id, + preset: preset, + groupNames: groupView?.names, + groupNotMuted: groupView?.enabledNames, + ); + final beforeGlobals = _jsonString(_globalVariables); + late final String expanded; + try { + // Adapted from SillyTavern (AGPL-3.0), commit + // 7e8663cd9c184a550b37238218bdd32c6efc68e9: + // public/script.js::sendMessageAsUser and + // public/scripts/slash-commands.js::sendNarratorMessage. + // sendMessageAsUser applies USER_INPUT before substitution. + // sendNarratorMessage instead substitutes text.trim(), without regex 3. + final prepared = narrator + ? trimJsWhitespace(text) + : environment.regex.apply(text, placement: RegexPlacement.userInput, expand: environment.regexExpand); + expanded = environment.expand(prepared, const {}); + } catch (prepareError, prepareStackTrace) { + // Local state is detached, but preparation uses ST/T11's real globals. + // Preserve both failures when those effects cannot be saved either. + try { if (beforeGlobals != _jsonString(_globalVariables)) { await _saveDocument(); } - final nextState = { - ...state, - 'variables': _copy(local), - 'stChatId': environment.chatId, - 'stChatIdHash': environment.chatIdHash, - }; - _initializeDshState(_stateDefinition(record.card.data), nextState, settings['stateEnabled'] != false); - final narratorName = metadata['narrator_name']; - final name = narrator - ? speaker ?? (narratorName is String && narratorName.isNotEmpty ? narratorName : 'System') - : environment.userName; - final message = _store.transaction(() { - final message = _store.append( - chatId, - parentId: leaf, - role: narrator ? TavernRole.narrator : TavernRole.user, - speaker: name, - text: expanded, - extra: narrator ? const {'type': 'narrator'} : const {}, - state: nextState, - ); - _store.updateChat( - chatId, - variables: _copy(local), - metadata: _runtimeMetadata(_store.chat(chatId).metadata, nextState), - ); - return message; - }); - _liveVariables[chatId] = (leaf: message.nodeId, values: _runtimeCopy(local)); - return message; - }); + } catch (saveError, saveStackTrace) { + Error.throwWithStackTrace( + _PreparationSaveFailure( + prepareError, + prepareStackTrace, + saveError, + saveStackTrace, + operation: narrator ? 'narrate' : 'sendOnly', + ), + saveStackTrace, + ); + } + Error.throwWithStackTrace(prepareError, prepareStackTrace); + } + // Save preparation effects before SQL: a failed write must not append a + // manual message. Later SQL failure rolls back local state and the tree, + // while already saved global preparation effects remain committed. + if (beforeGlobals != _jsonString(_globalVariables)) { + await _saveDocument(); + } + final nextState = { + ...state, + 'variables': _copy(local), + 'stChatId': environment.chatId, + 'stChatIdHash': environment.chatIdHash, + }; + _initializeDshState( + _stateDefinition(groupView?.host.card.data ?? record.card.data), + nextState, + settings['stateEnabled'] != false, + ); + final narratorName = metadata['narrator_name']; + final name = narrator + ? speaker ?? (narratorName is String && narratorName.isNotEmpty ? narratorName : 'System') + : environment.userName; + cancellation?.check(); + final message = _store.transaction(() { + final message = _store.append( + chatId, + parentId: leaf, + role: narrator ? TavernRole.narrator : TavernRole.user, + speaker: name, + text: expanded, + extra: narrator ? const {'type': 'narrator'} : const {}, + state: nextState, + ); + _store.updateChat( + chatId, + variables: _copy(local), + metadata: _runtimeMetadata(_store.chat(chatId).metadata, nextState), + ); + return message; + }); + _liveVariables[chatId] = (leaf: message.nodeId, values: _runtimeCopy(local)); + return message; + } @override Stream regenerate(String chatId, String nodeId) => @@ -2760,6 +2931,13 @@ class TavernEngine implements Tavern { try { _ensureOpen(); _store.chat(chatId); + if (_groups.rounds.containsKey(chatId)) { + controller.add( + const TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_requires_resumeGroupTurn'), + ); + unawaited(controller.close()); + return; + } final turn = _turns[chatId]; if (turn == null || turn.finished) { unawaited(controller.close()); @@ -2791,6 +2969,7 @@ class TavernEngine implements Tavern { @override void stop(String chatId) { + _groups.cancel(chatId); _turns[chatId]?.cancellation.cancel(); } @@ -2807,13 +2986,46 @@ class TavernEngine implements Tavern { return; } final chat = _store.chat(chatId); + if (_mainBusy(chatId) || _mutations.containsKey(chatId)) { + controller.add(const TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); + unawaited(controller.close()); + return; + } + final group = _store.groupChat(chatId); + if (group != null && (kind == _TurnKind.send || kind == _TurnKind.impersonate)) { + controller.add( + TavernTurnFailed( + TavernFailure.invalidParameters, + detail: kind == _TurnKind.send ? 'group_requires_sendGroup' : 'group_impersonate_unsupported', + ), + ); + unawaited(controller.close()); + return; + } + var actorId = chat.characterId; + var actorConfig = _config; + if (group != null) { + final targetId = nodeId == null ? null : _store.node(chatId, nodeId).message.speakerCharacterId; + final member = group.members.where((item) => item.characterId == targetId).firstOrNull; + if (member == null) { + controller.add( + const TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_actor_not_in_roster'), + ); + unawaited(controller.close()); + return; + } + actorId = member.characterId; + actorConfig = _groupModelConfig(_config, member.modelRole); + } final selection = _personas.resolve(personaId: chat.personaId); final presetSelection = _resolvePreset(chat).selection; - if (_turns.containsKey(chatId) || + if (_mainBusy(chatId) || _mutations.containsKey(chatId) || _globalPresetUpdate || _deletingPresets.contains(presetSelection.effectivePresetId) || _deletingCharacters.contains(chat.characterId) || + _deletingCharacters.contains(actorId) || + group?.members.any((member) => _deletingCharacters.contains(member.characterId)) == true || _deletingPersonas.contains(selection.persona?.id)) { controller.add(const TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); unawaited(controller.close()); @@ -2829,13 +3041,14 @@ class TavernEngine implements Tavern { input, nodeId, controller, - _config, + actorConfig, _personaSettings(_effectiveSettings(chat, metadata), selection), _clock(), selection, metadata, sourceFile is String ? sourceFile : null, presetSelection.effectivePresetId, + actorId: actorId, ); turn = running; running.directorCaptured = _directors.latestSuccess(chatId); @@ -2867,14 +3080,25 @@ class TavernEngine implements Tavern { try { beforeGlobals = _jsonString(_globalVariables); turn.originalVariables = _runtimeCopy(_liveVariables[chatId]?.values ?? turn.chat.variables); - turn.secretCardRevision = _directors._cardRevisions[turn.chat.characterId] ?? 0; + turn.secretCardRevision ??= _directors._cardRevisions[turn.chat.characterId] ?? 0; // Cancellation releases the turn, not its actual local file work. // Keep the original read owned until every character file has drained. - final record = await turn.cancellation.wait(_trackOperation(_record(turn.chat.characterId))); + if (turn.groupView == null) { + final group = _store.groupChat(chatId); + if (group != null) { + turn.groupView = await turn.cancellation.wait( + _trackOperation(_groups.capture(group, checkCancelled: turn.cancellation.check)), + ); + } + } + final record = turn.groupView == null + ? await turn.cancellation.wait(_trackOperation(_record(turn.actorId))) + : turn.groupView!.cards[turn.actorId]; if (record == null) throw const TavernImportError('角色不存在。'); turn.cancellation.check(); final data = _effectiveCard(record.card.data, turn.settings); - turn.directorCard = _copy(data); + final hostData = _effectiveCard(turn.groupView?.host.card.data ?? record.card.data, turn.settings); + turn.directorCard = _copy(hostData); StoredNode? target; if (turn.nodeId != null) { target = _store.node(chatId, turn.nodeId!); @@ -2888,7 +3112,9 @@ class TavernEngine implements Tavern { // A successful impersonation may update the terminal state of an empty // chat without adding a node. Only a turn starting at that empty current // end may resume it; an empty regenerate prefix has no such snapshot. - var state = parent == null && turn.chat.currentLeafId == null && turn.kind != _TurnKind.regenerate + var state = turn.groupView != null && parent == null + ? _stateAt(chatId, null) + : parent == null && turn.chat.currentLeafId == null && turn.kind != _TurnKind.regenerate ? _emptyChatState(turn.metadata) : _stateAt(chatId, parent); if (turn.sourceFile != null) state['chatJsonlSourceFile'] = turn.sourceFile; @@ -2902,16 +3128,16 @@ class TavernEngine implements Tavern { : _copy(turn.chat.variables); turn.variables = local; turn.state = state; - _bindSecretTurn(this, turn, data, parent, target, (next, restored) { + _bindSecretTurn(this, turn, hostData, parent, target, (next, restored) { state = next; turn.state = next; target = restored; turn.originalTarget = restored; }); - _prepareStateTurn(turn, data); + _prepareStateTurn(turn, hostData); final history = _historyTo(chatId, parent); - turn.directorPrepared = _directors.prepare(turn, data, parent); - _prepareCardEventTurn(this, turn, data); + turn.directorPrepared = _directors.prepare(turn, hostData, parent); + _prepareCardEventTurn(this, turn, hostData); // An inactive target can rewrite remembered edges outside the old active // path. Preserve only edges activation can change, including a null // selection with still-stored, unselected children below it. @@ -2946,7 +3172,9 @@ class TavernEngine implements Tavern { turn.generationType, chatIdHash: _finiteNumber(turn.metadata['chat_id_hash']), pickRerollSeed: turn.metadata['pick_reroll_seed'], - characterId: turn.chat.characterId, + characterId: turn.actorId, + groupNames: turn.groupView?.names, + groupNotMuted: turn.groupView?.enabledNames, preset: preset, ); turn.environment = environment; @@ -3003,9 +3231,10 @@ class TavernEngine implements Tavern { final books = await turn.cancellation.wait( _trackOperation( _loadBooks( - turn.chat.characterId, + turn.actorId, data, turn.settings, + group: turn.groupView, personaId: turn.personaSelection.persona?.id, stWorldNames: {if (activeWorldName is String && activeBookId is String) activeBookId: activeWorldName}, checkCancelled: turn.cancellation.check, @@ -3052,8 +3281,8 @@ class TavernEngine implements Tavern { scenario: prepared['scenario'], creatorNotes: prepared['creator_notes'], trigger: turn.generationType, - characterName: _characterFilename(turn.settings, turn.chat.characterId), - characterTags: _characterTags(turn.settings, turn.chat.characterId), + characterName: _characterFilename(turn.settings, turn.actorId), + characterTags: _characterTags(turn.settings, turn.actorId), timedWorldInfo: _map(state['timedWorldInfo']), decoratorState: _map(state['decoratorState']), assistantMessageCount: eligibleHistory.where((node) => node.message.role == TavernRole.character).length, @@ -3084,7 +3313,7 @@ class TavernEngine implements Tavern { ..._finishInjections(initialInjections, world, deepseek: turn.config.deepseek), ..._stateTurnInjections(turn), ..._cardEventTurnInjections(turn), - ..._secretTurnInjections(this, turn, data), + ..._secretTurnInjections(this, turn, hostData), ..._directorInjections(turn), ]; final examples = [for (var index = 0; index < world.activated.length; index++) (index, world.activated[index])]; @@ -3101,7 +3330,8 @@ class TavernEngine implements Tavern { history: [for (final node in promptHistory) _promptHistory(node)], model: turn.config.model, userName: environment.userName, - characterId: turn.chat.characterId, + characterId: turn.actorId, + groupNames: turn.groupView?.names ?? const [], generationType: turn.generationType, deepseek: turn.config.deepseek, persona: environment.persona, @@ -3165,6 +3395,17 @@ class TavernEngine implements Tavern { _captureCardEventAssembly(turn, assembly); _captureSecretAssembly(turn, assembly); _captureDirectorAssembly(this, turn, assembly, world); + if (turn.groupRound case final round?) { + _setInspection( + turn, + turn.inspection.copyWith( + director: { + ...turn.inspection.director, + 'groupSelection': {'roundId': round.id, 'plannedCharacterIds': round.planned, 'issues': round.issues}, + }, + ), + ); + } if (turn.stateDefinition != null || _hasSecretTrace(turn)) { _setInspection(turn, turn.inspection.copyWith(state: _statePreparationTrace(turn))); } @@ -3195,15 +3436,28 @@ class TavernEngine implements Tavern { _store.selectNode(chatId, message.nodeId, restoreDescendants: false); final extra = _copy(continuationTarget.extra); extra['dshgo'] = {..._map(extra['dshgo']), 'tavernState': turn.currentState}; - message = _store.updateMessage(chatId, message.nodeId, extra: extra); + if (turn.groupView != null) { + final own = _map(extra['dshgo']); + own['group'] = { + ..._map(own['group']), + 'version': 1, + 'actorCharacterId': turn.actorId, + 'model': turn.config.model, + 'deepseek': turn.config.deepseek, + }; + extra['dshgo'] = own; + } + message = _store.updateMessage(chatId, message.nodeId, extra: extra, speakerCharacterId: turn.actorId); } else { message = _store.append( chatId, parentId: parent, role: TavernRole.character, speaker: record.card.promptName, + speakerCharacterId: turn.actorId, text: '', state: turn.currentState, + extra: _groupReplyExtra(turn), ); } final inspection = turn.inspection.copyWith(nodeId: message.nodeId); @@ -3225,7 +3479,9 @@ class TavernEngine implements Tavern { turn.cancellation.check(); final stateSettlement = _settleStateTurn(this, turn, successful: true); turn.stateAttempt = stateSettlement; - final storySettlement = _settleStoryTurn(this, turn, stateSettlement?.state ?? turn.currentState); + final storySettlement = turn.groupRound == null + ? _settleStoryTurn(this, turn, stateSettlement?.state ?? turn.currentState) + : null; turn.storyAttempt = storySettlement; _commitTurn( turn, @@ -3233,9 +3489,10 @@ class TavernEngine implements Tavern { stateSettlement: stateSettlement, storySettlement: storySettlement, ); + _groups.actorCommitted(turn); turn.emit(TavernTurnDone(_latest(turn), finishReason: turn.finishReason)); turn.terminal = true; - _directors.afterTurn(turn); + if (turn.groupRound == null) _directors.afterTurn(turn); } catch (error) { var failure = error is TavernModelException ? error.kind @@ -3329,6 +3586,7 @@ class TavernEngine implements Tavern { String? personaId, Map stWorldNames = const {}, void Function()? checkCancelled, + _GroupView? group, }) async { final result = []; final missing = >[]; @@ -3336,7 +3594,10 @@ class TavernEngine implements Tavern { for (final source in WorldInfoSource.values) { final ids = [ ..._strings(bindings[source.name]), - if (source == WorldInfoSource.character) ..._strings(_map(settings['characterLorebooks'])[characterId]), + if (source == WorldInfoSource.character) + for (final id + in group == null ? [characterId] : [for (final member in group.group.members) member.characterId]) + ..._strings(_map(settings['characterLorebooks'])[id]), if (source == WorldInfoSource.persona && personaId != null) ..._strings(_map(settings['personaLorebooks'])[personaId]), ]; @@ -3359,9 +3620,17 @@ class TavernEngine implements Tavern { } } checkCancelled?.call(); - final embedded = data['character_book']; - if (embedded is Map) { - result.add(WorldInfoBook.fromJson(id: '$characterId:card', source: WorldInfoSource.character, json: embedded)); + final embeddedCards = group == null + ? >{characterId: data} + : { + for (final member in group.group.members) + if (group.cards[member.characterId] case final card?) member.characterId: card.card.data, + }; + for (final card in embeddedCards.entries) { + final embedded = card.value['character_book']; + if (embedded is Map) { + result.add(WorldInfoBook.fromJson(id: '${card.key}:card', source: WorldInfoSource.character, json: embedded)); + } } return (loaded: result, missing: missing); } @@ -3594,6 +3863,7 @@ class TavernEngine implements Tavern { parentId: reply.parentId, role: reply.role, speaker: reply.speaker, + speakerCharacterId: reply.speakerCharacterId, text: turn.text!.toString(), reasoning: reasoning.isEmpty && reply.reasoning == null ? null : reasoning, at: reply.at, @@ -3643,6 +3913,8 @@ class TavernEngine implements Tavern { text: message.text, reasoning: message.reasoning, clearReasoning: message.reasoning == null, + speakerCharacterId: message.speakerCharacterId, + clearSpeakerCharacterId: message.speakerCharacterId == null, usage: message.usage, clearUsage: message.usage == null, extra: original.extra, @@ -3750,7 +4022,7 @@ class TavernEngine implements Tavern { @override Future backup(String destinationPath) { _ensureOpen(); - if (_backupInProgress || _turns.isNotEmpty || _directors.hasJobs) { + if (_backupInProgress || _turns.isNotEmpty || _groups.rounds.isNotEmpty || _directors.hasJobs) { return Future.error(const TavernModelException(TavernFailure.busy, 'backup_busy')); } // Close admission before draining. Capture the prior operations BEFORE @@ -3813,14 +4085,21 @@ class TavernEngine implements Tavern { Future _close() async { _closing = true; + for (final group in _groups.rounds.values) { + group.cancel(); + } for (final turn in _turns.values) { turn.cancellation.cancel(); } await _directors.close(); // Unblock guarded work before waiting for operations that may own it. await Future.wait([_regexWorker.close(), _keywordWorker.close()]); - while (_turns.isNotEmpty || _operations.isNotEmpty) { - await Future.wait([..._operations, for (final turn in _turns.values) turn.done.future]); + while (_turns.isNotEmpty || _groups.rounds.isNotEmpty || _operations.isNotEmpty) { + await Future.wait([ + ..._operations, + for (final turn in _turns.values) turn.done.future, + for (final group in _groups.rounds.values) group.done.future, + ]); } await _fileQueue; await _libraryQueue; @@ -3909,16 +4188,19 @@ class _Turn { this.kind, this.input, this.nodeId, - StreamController controller, + StreamController? controller, this.config, this.settings, this.at, this.personaSelection, this.metadata, this.sourceFile, - this.presetId, - ) { - observers.add(controller); + this.presetId, { + String? actorId, + this.groupView, + this.groupRound, + }) : actorId = actorId ?? chat.characterId { + if (controller != null) observers.add(controller); inspection = TavernTurnInspection( chatId: chat.id, contextNodeId: chat.currentLeafId, @@ -3939,6 +4221,10 @@ class _Turn { final Map metadata; final String? sourceFile; final String? presetId; + final String actorId; + _GroupView? groupView; + final _GroupRound? groupRound; + void Function(TavernEvent)? onEvent; final cancellation = ModelCancellation(); final done = Completer(); bool finished = false; @@ -4024,6 +4310,7 @@ class _Turn { void emit(TavernEvent event) { if (terminal) return; + onEvent?.call(event); for (final observer in observers) { if (!observer.isClosed) observer.add(event); } @@ -4035,6 +4322,7 @@ TavernMessage _withReportedUsage(TavernMessage message, TavernUsage? usage) => T parentId: message.parentId, role: message.role, speaker: message.speaker, + speakerCharacterId: message.speakerCharacterId, text: message.text, reasoning: message.reasoning, at: message.at, @@ -4061,6 +4349,8 @@ class _MacroEnvironment { this.chatIdHash, this.pickRerollSeed, this.characterId = '', + this.groupNames, + this.groupNotMuted, ChatPreset? preset, }) { final raw = _map(settings['preset']); @@ -4078,6 +4368,8 @@ class _MacroEnvironment { num? chatIdHash; final Object? pickRerollSeed; final String characterId; + final List? groupNames; + final List? groupNotMuted; final String input; final String generationType; final outlets = {}; @@ -4114,6 +4406,8 @@ class _MacroEnvironment { localVariables: local, globalVariables: global, card: card, + group: groupNames?.join(', '), + groupNotMuted: groupNotMuted?.join(', '), userName: userName, persona: persona, history: _MacroHistory(history), @@ -4452,6 +4746,14 @@ List _preparePromptHistory( var additions = 0; for (var index = transformed.length - 1; index >= 0; index--) { final node = transformed[index]; + if (environment.groupNames != null) { + final provenance = _map(_map(node.extra['dshgo'])['group']); + if (node.message.speakerCharacterId != environment.characterId || + provenance['model'] != environment.config.model || + provenance['deepseek'] != environment.config.deepseek) { + continue; + } + } final depth = transformed.length - index - (continuing ? 2 : 1); final reasoning = environment.regex.apply( node.message.reasoning ?? '', @@ -4574,6 +4876,7 @@ StoredNode _nodeWithText(StoredNode node, String text) { parentId: value.parentId, role: value.role, speaker: value.speaker, + speakerCharacterId: value.speakerCharacterId, text: text, reasoning: value.reasoning, at: value.at, diff --git a/packages/dsh_tavern/lib/src/engine_director.dart b/packages/dsh_tavern/lib/src/engine_director.dart index 1703287a7f..06c4665d09 100644 --- a/packages/dsh_tavern/lib/src/engine_director.dart +++ b/packages/dsh_tavern/lib/src/engine_director.dart @@ -46,12 +46,17 @@ Map _directorPersona(PersonaSelection selection, Map _directorSource(TavernEngine engine, String chatId, String? nodeId) { final history = engine._store.historyTo(chatId, nodeId); final visible = history.where((node) => !node.message.hidden && !_systemMessage(node)).toList(); - final saved = nodeId == null + final group = engine._store.groupChat(chatId); + final saved = nodeId == null && group == null ? _emptyChatState(engine._chatCodec.metadataForNode(engine._store, chatId)) : engine._stateAt(chatId, nodeId); final input = _StateInput(saved); return _copy({ 'kind': nodeId == null ? 'empty' : 'node', 'nodeId': nodeId, + if (group != null) + 'groupMembers': [ + for (final member in group.members) {'characterId': member.characterId, 'muted': member.muted}, + ], // Include every ancestor id to invalidate deletion/reparenting without // comparing a later descendant's state with this immutable source. 'path': [for (final node in history) node.message.nodeId], @@ -65,6 +70,7 @@ Map _directorSource(TavernEngine engine, String chatId, String? 'role': node.message.role.name, 'speaker': node.message.speaker, 'text': node.message.text, + if (group != null) 'speakerCharacterId': node.message.speakerCharacterId, 'hidden': node.message.hidden, 'system': _systemMessage(node), }, @@ -76,6 +82,7 @@ Map _directorSource(TavernEngine engine, String chatId, String? 'role': node.message.role.name, 'speaker': node.message.speaker, 'text': node.message.text, + if (group != null) 'speakerCharacterId': node.message.speakerCharacterId, }, ], 'state': { @@ -173,11 +180,13 @@ class _DirectorJob { required this.cardRevision, this.card, this.secret = false, + this.group, }) : id = 'director-${_newStateId()}', requestKey = _directorHash({'source': source, 'settings': settings, 'persona': persona, 'secret': secret}); final String id; final String chatId; final String characterId; + final TavernGroupChat? group; final String? nodeId; final Map source; final Map settings; @@ -309,13 +318,14 @@ class _DirectorCoordinator { secret: secret, cardRevision: cardRevision ?? _cardRevisions[chat.characterId] ?? 0, card: card == null ? null : _copy(card), + group: engine._store.groupChat(chatId), ); } Future manual(String chatId) { engine._ensureAdmission(); final chat = engine._store.chat(chatId); - if (engine._turns.containsKey(chatId) || + if (engine._mainBusy(chatId) || engine._mutations.containsKey(chatId) || engine._deletingCharacters.contains(chat.characterId)) { throw const TavernModelException(TavernFailure.busy, 'director_context_busy'); @@ -577,7 +587,11 @@ class _DirectorCoordinator { final story = _storyDirectorContext(job.card!, job.source); job.storyPhase = story.phase; job.eventCapability = _cardEventCapability(job.card!, job.settings); + final groupDirectory = job.group == null + ? null + : await job.cancellation.wait(_prepareIO(job, engine._groups.directory(job.group!))); final input = jsonEncode({ + 'groupMembers': ?groupDirectory, 'card': secretInput.card, 'persona': job.persona, 'summaries': summaries.skip(max(0, summaries.length - 20)).toList(), @@ -813,17 +827,13 @@ class _DirectorCoordinator { kind: 'director.event', at: now, result: { - 'version': 1, - 'runId': job.id, - 'source': 'director', - 'status': 'pending', - 'sourceKind': raw['sourceKind'], - 'sourceNodeId': job.nodeId, - 'sourceFingerprint': job.fingerprint, - 'createdAt': now.toUtc().toIso8601String(), - if (job.eventCapability != null) 'outputIndex': i, - 'name': events[i]['name'], - 'data': events[i]['data'], + 'version': 1, 'runId': job.id, 'source': 'director', 'status': 'pending', + 'sourceKind': raw['sourceKind'], 'sourceNodeId': job.nodeId, + 'sourceFingerprint': job.fingerprint, 'createdAt': now.toUtc().toIso8601String(), + // Group routing authenticates this position even when the + // host defines no executable card event rules. + if (job.eventCapability != null || job.group != null) 'outputIndex': i, + 'name': events[i]['name'], 'data': events[i]['data'], }, ), ); @@ -941,6 +951,8 @@ class _DirectorCoordinator { final chat = engine._store.chat(chatId); final selection = engine._personas.resolve(personaId: chat.personaId); final issues = >[...?_transientIssues[chatId]]; + final groupSelection = engine._store.agentRunsByKind(chatId, const {'group.selection'}, limit: 1).firstOrNull; + if (groupSelection != null) issues.addAll(_stateRows(groupSelection.result['issues'])); Map? readSettings() { try { return _personaSettings(engine._effectiveSettings(chat), selection); diff --git a/packages/dsh_tavern/lib/src/engine_group.dart b/packages/dsh_tavern/lib/src/engine_group.dart new file mode 100644 index 0000000000..92c7a61fb7 --- /dev/null +++ b/packages/dsh_tavern/lib/src/engine_group.dart @@ -0,0 +1,921 @@ +part of 'engine.dart'; + +/// The real host owns definitions. Each actor keeps its own prompt and regex. +class _GroupView { + _GroupView(this.group, this.host, this.cards); + final TavernGroupChat group; + final StoredCharacter host; + final Map cards; + List get names => [ + for (final member in group.members) + if (cards[member.characterId] case final card?) card.card.promptName, + ]; + List get enabledNames => [ + for (final member in group.members) + if (!member.muted && cards[member.characterId] != null) cards[member.characterId]!.card.promptName, + ]; +} + +TavernGroupChat _groupCopy( + TavernGroupChat value, { + String? title, + List? members, + TavernGroupActivation? activation, + int? limit, + bool? selfResponses, +}) => TavernGroupChat( + chatId: value.chatId, + title: title ?? value.title, + hostCharacterId: value.hostCharacterId, + members: members ?? value.members, + activation: activation ?? value.activation, + maxSpeakersPerTurn: limit ?? value.maxSpeakersPerTurn, + allowSelfResponses: selfResponses ?? value.allowSelfResponses, +); + +TavernModelConfig _groupModelConfig(TavernModelConfig config, TavernGroupModelRole role) => + role == TavernGroupModelRole.main + ? config + : TavernModelConfig( + apiKey: config.apiKey, + baseUrl: config.baseUrl, + model: config.deepseek ? 'deepseek-flash' : config.model, + deepseek: config.deepseek, + thinking: false, + ); + +class _GroupCoordinator { + _GroupCoordinator(this.engine); + final TavernEngine engine; + final rounds = {}; + + TavernGroupChat require(String id) => + engine._store.groupChat(id) ?? (throw TavernGroupException(TavernGroupError.notGroup, chatId: id)); + + Future read(String id) async { + final group = engine._store.groupChat(id); + if (group == null) return null; + final existing = {for (final card in await engine._characters()) card.id}; + return _groupCopy( + group, + members: [ + for (final member in group.members) + TavernGroupMember( + characterId: member.characterId, + talkativeness: member.talkativeness, + muted: member.muted, + modelRole: member.modelRole, + available: existing.contains(member.characterId), + ), + ], + ); + } + + Future>> directory(TavernGroupChat group) async { + final names = {for (final card in await engine._characters()) card.id: card.name}; + return [ + for (final member in group.members) + {'characterId': member.characterId, 'name': names[member.characterId], 'muted': member.muted}, + ]; + } + + void _limit(int value) { + if (value < 1) throw TavernGroupException(TavernGroupError.invalidLimit); + } + + void _talk(double? value) { + if (value != null && (!value.isFinite || value < 0 || value > 1)) { + throw TavernGroupException(TavernGroupError.invalidTalkativeness); + } + } + + double _initialTalk(StoredCharacter card) { + final value = card.card.data['talkativeness'] ?? _map(card.card.data['extensions'])['talkativeness']; + final number = presetJsNumber(value); + return value == null || !number.isFinite ? 0.5 : number.clamp(0, 1).toDouble(); + } + + Future _card(String id) async { + if (engine._deletingCharacters.contains(id)) { + throw TavernGroupException(TavernGroupError.characterNotFound, characterId: id); + } + final value = await engine._record(id); + if (value == null || engine._deletingCharacters.contains(id)) { + throw TavernGroupException(TavernGroupError.characterNotFound, characterId: id); + } + return value; + } + + Future<_GroupView> capture(TavernGroupChat group, {void Function()? checkCancelled}) async { + checkCancelled?.call(); + final host = await _card(group.hostCharacterId); + checkCancelled?.call(); + final cards = {}; + for (final member in group.members) { + checkCancelled?.call(); + final card = member.characterId == host.id ? host : await engine._record(member.characterId); + checkCancelled?.call(); + if (card != null && !engine._deletingCharacters.contains(member.characterId)) cards[member.characterId] = card; + } + return _GroupView(group, host, cards); + } + + Future create( + List ids, { + String? hostCharacterId, + String? title, + required TavernGroupActivation activation, + required int maxSpeakersPerTurn, + required bool allowSelfResponses, + required bool addGreetings, + }) async { + _limit(maxSpeakersPerTurn); + if (ids.toSet().length != ids.length || ids.any((id) => id.isEmpty) || (hostCharacterId == null && ids.isEmpty)) { + throw TavernGroupException(TavernGroupError.invalidMembers); + } + final host = await _card(hostCharacterId ?? ids.first); + final cards = {}; + for (final id in ids) { + cards[id] = id == host.id ? host : await _card(id); + } + // A deletion can be admitted while an earlier member read awaits I/O. + // The library queue keeps the files intact, and this final admission + // check keeps a half-created group out of that deletion's path. + if (engine._deletingCharacters.contains(host.id) || ids.any(engine._deletingCharacters.contains)) { + throw TavernGroupException(TavernGroupError.characterNotFound); + } + final selection = engine._personas.resolve(); + if (engine._deletingPersonas.contains(selection.persona?.id) || + engine._globalPresetUpdate || + engine._deletingPresets.contains(engine._resolvePreset().selection.effectivePresetId)) { + throw StateError('Group creation resources are changing.'); + } + final settings = _personaSettings(engine._effectiveSettings(), selection); + final variables = {}; + final beforeGlobals = _jsonString(engine._globalVariables); + late final String id; + try { + id = engine._store.transaction(() { + final id = engine._store.createChat(characterId: host.id, title: title ?? host.card.name); + final group = TavernGroupChat( + chatId: id, + title: title ?? host.card.name, + hostCharacterId: host.id, + members: [ + for (final memberId in ids) + TavernGroupMember( + characterId: memberId, + talkativeness: _initialTalk(cards[memberId]!), + muted: false, + modelRole: memberId == host.id ? TavernGroupModelRole.main : TavernGroupModelRole.helper, + ), + ], + activation: activation, + maxSpeakersPerTurn: maxSpeakersPerTurn, + allowSelfResponses: allowSelfResponses, + ); + engine._store.saveGroup(group); + final view = _GroupView(group, host, cards); + var state = { + 'variables': variables, + 'timedWorldInfo': {}, + 'decoratorState': {}, + 'stChatId': id, + }; + _initializeDshState(_stateDefinition(host.card.data), state, settings['stateEnabled'] != false); + engine._store.setGroupInitialState(id, state); + final history = []; + if (addGreetings) { + for (final member in group.members) { + final card = cards[member.characterId]!; + final data = card.card.data; + var greeting = _string(data['first_mes']); + if (data['alternate_greetings'] is List) { + final choices = [ + greeting, + ..._strings(data['alternate_greetings']), + ].where((value) => trimJsWhitespace(value).isNotEmpty).toList(); + greeting = choices.isEmpty ? '' : choices[(engine._random() * choices.length).floor()]; + } + greeting = trimJsWhitespace(greeting); + if (greeting.isEmpty) continue; + final environment = _MacroEnvironment( + data, + settings, + variables, + engine._globalVariables, + history, + engine._config, + engine._clock(), + engine._random, + id, + '', + 'normal', + characterId: member.characterId, + groupNames: view.names, + groupNotMuted: view.enabledNames, + ); + final text = environment + .expand( + environment.regex.apply( + greeting, + placement: RegexPlacement.aiOutput, + expand: environment.regexExpand, + ), + const {}, + ) + .replaceAll('\r', ''); + state = {...state, 'variables': _copy(variables), 'stChatIdHash': environment.chatIdHash}; + final message = engine._store.append( + id, + parentId: history.lastOrNull?.message.nodeId, + role: TavernRole.character, + speaker: card.card.promptName, + speakerCharacterId: member.characterId, + text: text, + state: state, + ); + history.add(engine._store.node(id, message.nodeId)); + } + } + engine._store.updateChat( + id, + variables: _copy(variables), + metadata: _runtimeMetadata(engine._store.chat(id).metadata, state), + ); + return id; + }); + } catch (createError, createStackTrace) { + try { + if (beforeGlobals != _jsonString(engine._globalVariables)) await engine._saveDocument(); + } catch (saveError, saveStackTrace) { + Error.throwWithStackTrace( + _PreparationSaveFailure( + createError, + createStackTrace, + saveError, + saveStackTrace, + operation: 'createGroupChat', + ), + saveStackTrace, + ); + } + Error.throwWithStackTrace(createError, createStackTrace); + } + engine._liveVariables[id] = (leaf: engine._store.chat(id).currentLeafId, values: _runtimeCopy(variables)); + if (beforeGlobals != _jsonString(engine._globalVariables)) await engine._saveDocument(); + engine._notifyLists(); + return id; + } + + Future add( + String id, + String characterId, { + double? talkativeness, + required bool muted, + TavernGroupModelRole? modelRole, + }) async { + _talk(talkativeness); + final group = require(id); + if (group.members.any((member) => member.characterId == characterId)) { + throw TavernGroupException(TavernGroupError.memberExists, chatId: id, characterId: characterId); + } + final card = await _card(characterId); + engine._store.saveGroup( + _groupCopy( + group, + members: [ + ...group.members, + TavernGroupMember( + characterId: characterId, + talkativeness: talkativeness ?? _initialTalk(card), + muted: muted, + modelRole: + modelRole ?? + (characterId == group.hostCharacterId ? TavernGroupModelRole.main : TavernGroupModelRole.helper), + ), + ], + ), + ); + return (await read(id))!; + } + + Future updateMember( + String id, + String characterId, { + double? talkativeness, + bool? muted, + TavernGroupModelRole? modelRole, + }) async { + _talk(talkativeness); + final group = require(id); + _member(group, characterId); + engine._store.saveGroup( + _groupCopy( + group, + members: [ + for (final member in group.members) + if (member.characterId == characterId) + TavernGroupMember( + characterId: characterId, + talkativeness: talkativeness ?? member.talkativeness, + muted: muted ?? member.muted, + modelRole: modelRole ?? member.modelRole, + ) + else + member, + ], + ), + ); + return (await read(id))!; + } + + TavernGroupMember _member(TavernGroupChat group, String id) => + group.members.where((member) => member.characterId == id).firstOrNull ?? + (throw TavernGroupException(TavernGroupError.memberNotFound, chatId: group.chatId, characterId: id)); + Future remove(String id, String characterId) async { + final group = require(id); + _member(group, characterId); + engine._store.saveGroup( + _groupCopy(group, members: group.members.where((member) => member.characterId != characterId).toList()), + ); + return (await read(id))!; + } + + Future reorder(String id, List ids) async { + final group = require(id); + if (ids.length != group.members.length || + ids.toSet().length != ids.length || + ids.any((id) => !group.members.any((member) => member.characterId == id))) { + throw TavernGroupException(TavernGroupError.invalidOrder, chatId: id); + } + engine._store.saveGroup(_groupCopy(group, members: [for (final id in ids) _member(group, id)])); + return (await read(id))!; + } + + Stream send(String chatId, String input, {String? speakerCharacterId}) { + late StreamController controller; + _GroupRound? admitted; + controller = StreamController( + onListen: () { + try { + engine._ensureOpen(); + if (engine._mainBusy(chatId) || engine._mutations.containsKey(chatId)) { + throw const TavernModelException(TavernFailure.busy, 'chat_busy'); + } + final group = require(chatId); + final chat = engine._store.chat(chatId); + final persona = engine._personas.resolve(personaId: chat.personaId); + final preset = engine._resolvePreset(chat).selection; + if (engine._capturingBackup || + engine._mainBusy(chatId) || + engine._mutations.containsKey(chatId) || + engine._globalPresetUpdate || + engine._deletingPresets.contains(preset.effectivePresetId) || + engine._deletingPersonas.contains(persona.persona?.id) || + engine._deletingCharacters.contains(group.hostCharacterId) || + group.members.any((member) => engine._deletingCharacters.contains(member.characterId))) { + throw const TavernModelException(TavernFailure.busy, 'chat_busy'); + } + if (speakerCharacterId != null) _member(group, speakerCharacterId); + final metadata = engine._chatCodec.metadataForNode(engine._store, chatId); + final round = _GroupRound( + group, + input, + speakerCharacterId, + engine._clock(), + engine._config, + _personaSettings(engine._effectiveSettings(chat, metadata), persona), + persona, + preset.effectivePresetId, + engine._directors.latestSuccess(chatId), + engine._store.agentRunsByKind(chatId, const {'director.secret_proposal'}, limit: 1).firstOrNull, + engine._directors._cardRevisions[group.hostCharacterId] ?? 0, + ); + admitted = round; + round.observers.add(controller); + rounds[chatId] = round; + round.emit(TavernGroupStarted(round.progress)); + unawaited(_run(round)); + } catch (error) { + final failure = error is TavernModelException + ? error.kind + : error is TavernGroupException + ? TavernFailure.invalidParameters + : TavernFailure.server; + final code = error is TavernModelException + ? error.detail + : error is TavernGroupException + ? error.code.name + : 'group_admission_failed'; + controller.add( + TavernGroupFinished( + chatId: chatId, + reason: TavernGroupEndReason.failed, + failure: failure, + code: code, + detail: _safeDetail(error), + ), + ); + unawaited(controller.close()); + } + }, + onCancel: () { + admitted?.observers.remove(controller); + }, + ); + return controller.stream; + } + + Stream resume(String id) { + late StreamController controller; + _GroupRound? observed; + controller = StreamController( + onListen: () { + try { + engine._ensureOpen(); + final round = rounds[id]; + if (round == null) { + unawaited(controller.close()); + return; + } + observed = round; + round.observers.add(controller); + final child = round.child; + controller.add( + TavernGroupSnapshot( + progress: round.progress, + message: child?.reply == null ? null : engine._buffered(child!), + ), + ); + } catch (error) { + controller.add( + TavernGroupFinished( + chatId: id, + reason: TavernGroupEndReason.failed, + failure: TavernFailure.server, + code: 'group_resume_failed', + detail: _safeDetail(error), + ), + ); + unawaited(controller.close()); + } + }, + onCancel: () { + observed?.observers.remove(controller); + }, + ); + return controller.stream; + } + + Future _run(_GroupRound round) async { + _Turn? last; + try { + // Finish actual local reads before releasing a cancelled batch. + round.view = await capture(round.group, checkCancelled: round.cancellation.check); + round.cancellation.check(); + final history = engine._historyTo(round.group.chatId, engine._store.chat(round.group.chatId).currentLeafId); + round.planned.addAll(_select(round, history)); + round.remaining.addAll(round.planned); + if (round.explicitActor == null && round.group.activation == TavernGroupActivation.director) { + engine._store.writeAgentRun( + StoredAgentRun( + id: '${round.id}:selection', + chatId: round.group.chatId, + nodeId: history.lastOrNull?.message.nodeId, + kind: 'group.selection', + at: round.at, + result: { + 'version': 1, + 'roundId': round.id, + 'sourceRunId': round.directorCaptured?.id, + 'plannedCharacterIds': round.planned, + 'issues': round.issues, + }, + ), + ); + engine._directors.changed(round.group.chatId); + } + if (round.input.isNotEmpty) { + final actor = round.planned.isEmpty ? round.view!.host : round.view!.cards[round.planned.first]!; + final config = round.planned.isEmpty + ? round.config + : _groupModelConfig(round.config, _member(round.group, round.planned.first).modelRole); + final message = await engine._appendMessage( + round.group.chatId, + round.input, + narrator: false, + groupView: round.view, + actor: actor, + settingsOverride: round.settings, + configOverride: config, + cancellation: round.cancellation, + ); + round.userNodeId = message.nodeId; + } + round.cancellation.check(); + round.phase = TavernGroupPhase.ready; + round.emit(TavernGroupProgressChanged(round.progress)); + if (round.planned.isEmpty) { + _finish(round, TavernGroupEndReason.noSpeakers, detail: round.issues.isEmpty ? null : jsonEncode(round.issues)); + return; + } + for (final actorId in round.planned) { + round.cancellation.check(); + final chat = engine._store.chat(round.group.chatId); + final metadata = engine._chatCodec.metadataForNode(engine._store, chat.id); + final source = + engine._stateAt(chat.id, chat.currentLeafId)['chatJsonlSourceFile'] ?? + _map(_map(chat.metadata['dshgo'])['chatJsonl'])['rootFile']; + final member = _member(round.group, actorId); + final config = _groupModelConfig(round.config, member.modelRole); + final child = _Turn( + chat, + _TurnKind.send, + '', + null, + null, + config, + _copy(round.settings), + engine._clock(), + round.persona, + metadata, + source is String ? source : null, + round.presetId, + actorId: actorId, + groupView: round.view, + groupRound: round, + ); + child.directorCaptured = round.directorCaptured; + child.secretCaptured = _secretsEnabled(round.settings) ? round.secretCaptured : null; + child.secretCardRevision = round.hostRevision; + round.child = child; + round.current = actorId; + round.generationId = 'generation-${_newStateId()}'; + round.remaining.removeAt(0); + round.phase = TavernGroupPhase.speaking; + round.failed = null; + child.onEvent = (event) { + if (event is TavernTurnFailed) round.failed = event; + round.emit(TavernGroupMemberEvent(progress: round.progress, characterId: actorId, event: event)); + }; + engine._turns[chat.id] = child; + engine._setInspection(child, child.inspection); + round.emit(TavernGroupProgressChanged(round.progress)); + await engine._run(child); + final failure = round.failed; + if (failure != null) { + _finish( + round, + failure.kind == TavernFailure.cancelled ? TavernGroupEndReason.cancelled : TavernGroupEndReason.failed, + failure: failure.kind, + code: failure.kind == TavernFailure.server ? 'group_actor_failed' : failure.kind.name, + detail: failure.detail, + partial: failure.partial, + ); + return; + } + last = child; + if (child.reply != null) round.completed.add(child.reply!.nodeId); + round.child = null; + round.current = null; + round.generationId = null; + round.cancellation.check(); + } + if (last != null) { + // Only a complete batch advances the chapter and automatic helper tick. + last.state = engine._stateAt(last.chat.id, last.reply!.nodeId); + last.variables = _runtimeCopy(_map(last.state['variables'])); + final story = _settleStoryTurn(engine, last, last.currentState); + last.storyAttempt = story; + if (story != null) engine._commitTurn(last, last.inspection, storySettlement: story); + engine._directors.afterTurn(last); + } + _finish(round, TavernGroupEndReason.completed); + } catch (error) { + final failure = error is TavernModelException + ? error.kind + : error is TavernGroupException || error is TavernImportError || error is FormatException + ? TavernFailure.invalidCard + : TavernFailure.server; + _finish( + round, + failure == TavernFailure.cancelled ? TavernGroupEndReason.cancelled : TavernGroupEndReason.failed, + failure: failure, + code: failure == TavernFailure.server ? 'group_storage_failure' : failure.name, + detail: _safeDetail(error), + partial: round.child?.lastCommitted, + ); + } finally { + if (identical(rounds[round.group.chatId], round)) rounds.remove(round.group.chatId); + if (!round.done.isCompleted) round.done.complete(); + for (final observer in round.observers) { + unawaited(observer.close()); + } + round.observers.clear(); + engine._notifyLists(); + } + } + + void _finish( + _GroupRound round, + TavernGroupEndReason reason, { + TavernFailure? failure, + String? code, + String? detail, + TavernMessage? partial, + }) { + if (round.finished) return; + final attempted = round.current; + round.current = null; + round.generationId = null; + round.phase = TavernGroupPhase.finished; + if (reason == TavernGroupEndReason.completed || reason == TavernGroupEndReason.noSpeakers) round.remaining.clear(); + round.emit( + TavernGroupFinished( + chatId: round.group.chatId, + reason: reason, + progress: round.progress, + failure: failure, + code: code, + detail: detail, + attemptedCharacterId: attempted, + partial: partial, + ), + ); + round.finished = true; + } + + List _select(_GroupRound round, List history) { + final view = round.view!; + if (round.explicitActor case final id?) { + if (!view.cards.containsKey(id)) { + throw const TavernModelException(TavernFailure.invalidCard, 'group_actor_missing'); + } + return [id]; + } + final members = round.group.members + .where((member) => !member.muted && view.cards.containsKey(member.characterId)) + .toList(); + final ids = [for (final member in members) member.characterId]; + final selected = []; + final last = history.lastOrNull; + switch (round.group.activation) { + case TavernGroupActivation.manual: + return []; + case TavernGroupActivation.listOrder: + selected.addAll(ids); + break; + case TavernGroupActivation.director: + selected.addAll(_directorSelection(round, history, ids)); + break; + case TavernGroupActivation.randomPool: + if (ids.isEmpty) return []; + final spoken = {}; + if (round.input.isEmpty) { + for (final node in history.reversed) { + if (node.message.role == TavernRole.user) break; + if (_systemMessage(node) || node.message.role == TavernRole.narrator) continue; + if (node.message.speakerCharacterId case final id?) spoken.add(id); + } + } + var pool = ids.where((id) => !spoken.contains(id)).toList(); + if (pool.isEmpty) { + final prior = ids.length > 1 && last?.message.role != TavernRole.user + ? last?.message.speakerCharacterId + : null; + pool = prior == null ? ids : ids.where((id) => id != prior).toList(); + } + if (pool.isNotEmpty) selected.add(pool[(engine._random() * pool.length).floor()]); + break; + case TavernGroupActivation.natural: + // Adapted from ST 7e8663cd group-chats.js / utils.js (AGPL-3.0). + // Last system supplies no text, but its name can still ban a speaker. + final banned = + !round.group.allowSelfResponses && + round.input.isEmpty && + last != null && + last.message.role != TavernRole.user + ? last.message.speaker + : null; + final text = round.input.isNotEmpty + ? round.input + : last == null || _systemMessage(last) + ? '' + : last.message.text; + final words = RegExp(r'\b\w+\b', caseSensitive: false); + List tokens(String value) => [for (final match in words.allMatches(value)) match[0]!.toLowerCase()]; + for (final word in tokens(text)) { + for (final id in ids) { + final name = view.cards[id]!.card.name; + if (name != banned && tokens(name).contains(word)) { + selected.add(id); + break; + } + } + } + final shuffled = [...members]; + for (var count = shuffled.length; count > 0;) { + final index = (engine._random() * count).floor(); + count--; + final value = shuffled[count]; + shuffled[count] = shuffled[index]; + shuffled[index] = value; + } + final chatty = []; + for (final member in shuffled) { + if (view.cards[member.characterId]!.card.name == banned) continue; + if (member.talkativeness >= engine._random()) selected.add(member.characterId); + if (member.talkativeness > 0) chatty.add(member.characterId); + } + final naturalPool = chatty.isEmpty ? ids : chatty; + if (selected.isEmpty && naturalPool.isNotEmpty) { + selected.add(naturalPool[(engine._random() * naturalPool.length).floor()]); + } + break; + } + return selected.toSet().take(round.group.maxSpeakersPerTurn).toList(); + } + + List _directorSelection(_GroupRound round, List history, List enabled) { + final saved = round.directorCaptured; + if (saved == null) return []; + try { + final run = _directorRun(saved); + final reason = engine._directors._eligibility( + run, + saved, + _effectiveCard(round.view!.host.card.data, round.settings), + _directorPersona(round.persona, round.settings), + history.lastOrNull?.message.nodeId, + ); + if (reason != null) { + round.issues.add(_directorIssue(reason, id: saved.id)); + return []; + } + final output = director_core.DirectOutput.parse(run.output!); + final consumed = { + for (final node in history) ..._strings(_map(_map(node.extra['dshgo'])['group'])['directorEventIds']), + }; + final selected = []; + for (var index = 0; index < output.events.length; index++) { + final expected = output.events[index]; + if (expected['name'] != 'group.speaker') continue; + final eventId = '${saved.id}:event:$index'; + final stored = engine._store.agentRunById(round.group.chatId, eventId); + final event = stored == null ? null : engine._directors._event(stored); + if (stored == null || + stored.kind != 'director.event' || + event == null || + !event.available || + event.runId != saved.id || + event.name != expected['name'] || + event.data != expected['data'] || + event.sourceKind != run.sourceKind || + event.sourceNodeId != run.sourceNodeId || + event.sourceFingerprint != run.sourceFingerprint || + stored.result['outputIndex'] != index || + stored.result['createdAt'] != saved.result['finishedAt']) { + round.issues.add(_directorIssue('group_speaker_proposal_unavailable', id: eventId)); + continue; + } + if (consumed.contains(eventId)) continue; + Object? data; + try { + data = jsonDecode(event.data!); + } catch (_) { + /* Report below. */ + } + final id = data is Map ? data['characterId'] : null; + if (id is! String || !enabled.contains(id) || selected.contains(id)) { + round.issues.add(_directorIssue('group_speaker_not_eligible', id: eventId)); + continue; + } + selected.add(id); + round.directorEvents[id] = eventId; + } + return selected; + } catch (_) { + round.issues.add(_directorIssue('group_speaker_source_unavailable', id: saved.id)); + return []; + } + } + + void cancel(String id) => rounds[id]?.cancel(); + + void actorCommitted(_Turn turn) { + final round = turn.groupRound; + if (round == null) return; + // The script-bridge layer can extend this post-SQL boundary. This layer + // has no script host and does not manufacture a successful hook Ack. + round.phase = TavernGroupPhase.settling; + round.emit(TavernGroupProgressChanged(round.progress)); + } + + Future update( + String id, { + String? title, + TavernGroupActivation? activation, + int? maxSpeakersPerTurn, + bool? allowSelfResponses, + }) async { + if (maxSpeakersPerTurn != null) _limit(maxSpeakersPerTurn); + engine._store.saveGroup( + _groupCopy( + require(id), + title: title, + activation: activation, + limit: maxSpeakersPerTurn, + selfResponses: allowSelfResponses, + ), + ); + return (await read(id))!; + } +} + +Map _groupReplyExtra(_Turn turn) { + if (turn.groupView == null) return const {}; + final round = turn.groupRound; + return { + 'dshgo': { + 'group': { + 'version': 1, + 'actorCharacterId': turn.actorId, + 'model': turn.config.model, + 'deepseek': turn.config.deepseek, + if (round != null) 'roundId': round.id, + if (round != null) 'generationId': round.generationId, + if (round != null) 'memberIndex': round.planned.indexOf(turn.actorId), + 'directorEventIds': [?round?.directorEvents[turn.actorId]], + }, + }, + }; +} + +class _GroupRound { + _GroupRound( + this.group, + this.input, + this.explicitActor, + this.at, + this.config, + this.settings, + this.persona, + this.presetId, + this.directorCaptured, + this.secretCaptured, + this.hostRevision, + ); + final TavernGroupChat group; + final String input; + final String? explicitActor; + final DateTime at; + final TavernModelConfig config; + final Map settings; + final PersonaSelection persona; + final String? presetId; + final StoredAgentRun? directorCaptured; + final StoredAgentRun? secretCaptured; + final int hostRevision; + final String id = 'group-${_newStateId()}'; + final cancellation = ModelCancellation(); + final done = Completer(); + final observers = >{}; + final planned = []; + final remaining = []; + final completed = []; + final issues = >[]; + final directorEvents = {}; + _GroupView? view; + _Turn? child; + TavernTurnFailed? failed; + TavernGroupPhase phase = TavernGroupPhase.preparing; + String? current; + String? generationId; + String? userNodeId; + bool finished = false; + TavernGroupProgress get progress => TavernGroupProgress( + chatId: group.chatId, + roundId: id, + startedAt: at, + phase: phase, + userNodeId: userNodeId, + speakerCharacterId: current, + replyNodeId: current == null ? null : child?.reply?.nodeId, + generationId: generationId, + plannedCharacterIds: planned, + remainingCharacterIds: remaining, + completedNodeIds: completed, + ); + void cancel() { + cancellation.cancel(); + child?.cancellation.cancel(); + } + + void emit(TavernGroupEvent event) { + if (finished) return; + for (final observer in observers) { + if (!observer.isClosed) observer.add(event); + } + } +} diff --git a/packages/dsh_tavern/lib/src/engine_secrets.dart b/packages/dsh_tavern/lib/src/engine_secrets.dart index 7e4615ed43..2f91c712bb 100644 --- a/packages/dsh_tavern/lib/src/engine_secrets.dart +++ b/packages/dsh_tavern/lib/src/engine_secrets.dart @@ -307,7 +307,7 @@ Map _secretStateSource(Map saved) { /// Current authorization needs the selected saved state, not unrelated later /// ancestor bodies or their extra/original JSON. Source provenance is separate. Map _secretAuthorizationSource(TavernEngine engine, String chatId, String? nodeId) { - final saved = nodeId == null + final saved = nodeId == null && engine._store.groupChat(chatId) == null ? _emptyChatState(engine._chatCodec.metadataForNode(engine._store, chatId)) : engine._stateAt(chatId, nodeId); return _secretStateSource(saved); diff --git a/packages/dsh_tavern/lib/src/engine_state.dart b/packages/dsh_tavern/lib/src/engine_state.dart index 122d731d01..f4fda8c475 100644 --- a/packages/dsh_tavern/lib/src/engine_state.dart +++ b/packages/dsh_tavern/lib/src/engine_state.dart @@ -85,7 +85,7 @@ class _StateInput { Future _readSavedState(TavernEngine engine, String chatId) async { final chat = engine._store.chat(chatId); final leaf = chat.currentLeafId; - final saved = leaf == null ? {} : engine._stateAt(chatId, leaf); + final saved = engine._stateAt(chatId, leaf); final extra = leaf == null ? {} : engine._store.node(chatId, leaf).extra; final metadata = engine._chatCodec.metadataForNode(engine._store, chatId); final settings = engine._effectiveSettings(chat, metadata); @@ -201,7 +201,7 @@ Future _editSavedState( () => engine._withMutation(chatId, () async { final chat = engine._store.chat(chatId); final leaf = chat.currentLeafId; - final saved = leaf == null ? {} : engine._stateAt(chatId, leaf); + final saved = engine._stateAt(chatId, leaf); final extra = leaf == null ? {} : engine._store.node(chatId, leaf).extra; final settings = engine._effectiveSettings(chat, engine._chatCodec.metadataForNode(engine._store, chatId)); final record = await engine._record(chat.characterId); diff --git a/packages/dsh_tavern/lib/src/group.dart b/packages/dsh_tavern/lib/src/group.dart new file mode 100644 index 0000000000..d22c27d7c0 --- /dev/null +++ b/packages/dsh_tavern/lib/src/group.dart @@ -0,0 +1,150 @@ +part of 'api.dart'; + +enum TavernChatKind { single, group } + +enum TavernGroupActivation { natural, listOrder, randomPool, manual, director } + +enum TavernGroupModelRole { main, helper } + +class TavernGroupMember { + const TavernGroupMember({ + required this.characterId, + required this.talkativeness, + required this.muted, + required this.modelRole, + this.available = true, + }); + final String characterId; + final double talkativeness; + final bool muted; + final TavernGroupModelRole modelRole; + final bool available; +} + +class TavernGroupChat { + TavernGroupChat({ + required this.chatId, + required this.title, + required this.hostCharacterId, + required List members, + required this.activation, + required this.maxSpeakersPerTurn, + required this.allowSelfResponses, + }) : members = List.unmodifiable(members); + final String chatId; + final String title; + final String hostCharacterId; + final List members; + final TavernGroupActivation activation; + final int maxSpeakersPerTurn; + final bool allowSelfResponses; +} + +enum TavernGroupPhase { preparing, ready, speaking, settling, finished } + +enum TavernGroupEndReason { completed, noSpeakers, cancelled, failed } + +class TavernGroupProgress { + TavernGroupProgress({ + required this.chatId, + required this.roundId, + required this.startedAt, + required this.phase, + this.userNodeId, + this.speakerCharacterId, + this.replyNodeId, + this.generationId, + required List plannedCharacterIds, + required List remainingCharacterIds, + required List completedNodeIds, + }) : plannedCharacterIds = List.unmodifiable(plannedCharacterIds), + remainingCharacterIds = List.unmodifiable(remainingCharacterIds), + completedNodeIds = List.unmodifiable(completedNodeIds); + final String chatId; + final String roundId; + final DateTime startedAt; + final TavernGroupPhase phase; + final String? userNodeId; + final String? speakerCharacterId; + final String? replyNodeId; + final String? generationId; + final List plannedCharacterIds; + + /// Unstarted actors only; excludes the current actor. + final List remainingCharacterIds; + final List completedNodeIds; + String? get nextCharacterId => remainingCharacterIds.isEmpty ? null : remainingCharacterIds.first; + int get remainingCount => remainingCharacterIds.length; +} + +sealed class TavernGroupEvent { + const TavernGroupEvent(); +} + +class TavernGroupStarted extends TavernGroupEvent { + const TavernGroupStarted(this.progress); + final TavernGroupProgress progress; +} + +class TavernGroupProgressChanged extends TavernGroupEvent { + const TavernGroupProgressChanged(this.progress); + final TavernGroupProgress progress; +} + +class TavernGroupMemberEvent extends TavernGroupEvent { + const TavernGroupMemberEvent({required this.progress, required this.characterId, required this.event}); + final TavernGroupProgress progress; + final String characterId; + final TavernEvent event; +} + +class TavernGroupSnapshot extends TavernGroupEvent { + const TavernGroupSnapshot({required this.progress, this.message}); + final TavernGroupProgress progress; + final TavernMessage? message; +} + +class TavernGroupFinished extends TavernGroupEvent { + const TavernGroupFinished({ + required this.chatId, + required this.reason, + this.progress, + this.failure, + this.code, + this.detail, + this.attemptedCharacterId, + this.partial, + }); + final String chatId; + final TavernGroupEndReason reason; + final TavernGroupProgress? progress; + final TavernFailure? failure; + final String? code; + final String? detail; + final String? attemptedCharacterId; + final TavernMessage? partial; +} + +enum TavernGroupError { + notGroup, + characterNotFound, + memberExists, + memberNotFound, + invalidMembers, + invalidTalkativeness, + invalidLimit, + invalidOrder, + hostInUse, +} + +class TavernGroupException implements Exception { + TavernGroupException(this.code, {this.chatId, this.characterId, List relatedChatIds = const [], this.detail}) + : relatedChatIds = List.unmodifiable(relatedChatIds); + final TavernGroupError code; + final String? chatId; + final String? characterId; + final List relatedChatIds; + final String? detail; + @override + String toString() => 'TavernGroupException(${code.name}${detail == null ? '' : ': $detail'})'; +} diff --git a/packages/dsh_tavern/lib/src/io/chat_jsonl.dart b/packages/dsh_tavern/lib/src/io/chat_jsonl.dart index 6cea96a01b..68c4da52ff 100644 --- a/packages/dsh_tavern/lib/src/io/chat_jsonl.dart +++ b/packages/dsh_tavern/lib/src/io/chat_jsonl.dart @@ -76,6 +76,7 @@ final class ChatJsonlCodec { parentId: node.parent == null ? null : ids[node.parent], role: node.variant.role, speaker: node.variant.name, + speakerCharacterId: node.variant.speakerCharacterId, text: node.variant.text, reasoning: node.variant.reasoning, at: node.variant.at, @@ -501,6 +502,7 @@ final class _ExportTree { if (own.isNotEmpty && own['version'] != 1) { throw StateError('Unsupported stored dshgo.chatJsonl version.'); } + own.remove('speakerCharacterId'); final state = _state(node); own.addAll({ 'version': 1, @@ -509,6 +511,7 @@ final class _ExportTree { 'message': _messageFields(node), 'text': node.message.text, 'role': node.message.role.name, + 'speakerCharacterId': ?node.message.speakerCharacterId, 'hidden': node.message.hidden, 'usage': _usageJson(node.message.usage), 'at': node.message.at.toUtc().toIso8601String(), @@ -805,6 +808,7 @@ final class _Variant { ? TavernRole.system : TavernRole.character; hidden = original['is_system'] == true; + speakerCharacterId = role == TavernRole.character ? own['speakerCharacterId'] as String? : null; if (role == TavernRole.system && own['hidden'] is bool) { hidden = own['hidden'] as bool; } @@ -880,6 +884,7 @@ final class _Variant { if (!_swipeFields.contains(entry.key)) entry.key: entry.value, }, 'role': role.name, + 'speakerCharacterId': speakerCharacterId, 'hidden': hidden, 'rawSwipe': rawSwipe, 'usage': _usageJson(usage), @@ -892,6 +897,7 @@ final class _Variant { late Map state; late String text; late String name; + late String? speakerCharacterId; late TavernRole role; late bool hidden; late String? reasoning; @@ -1152,7 +1158,15 @@ Map _checkedEnvelope(Object? value, StChatDocument document, in if (result.isNotEmpty && result['version'] != 1) { _error(document, line, 'Unsupported dshgo.chatJsonl version; no part of this batch was imported.'); } - for (final key in ['nodeId', 'selectedChildId', 'text', 'at', 'sourceFileName', 'runtimeContext']) { + for (final key in [ + 'nodeId', + 'selectedChildId', + 'speakerCharacterId', + 'text', + 'at', + 'sourceFileName', + 'runtimeContext', + ]) { if (result[key] != null && result[key] is! String) { _error(document, line, 'chatJsonl.$key must be text.'); } diff --git a/packages/dsh_tavern/lib/src/store/chat_store.dart b/packages/dsh_tavern/lib/src/store/chat_store.dart index f6cb7b760d..4a54e996b9 100644 --- a/packages/dsh_tavern/lib/src/store/chat_store.dart +++ b/packages/dsh_tavern/lib/src/store/chat_store.dart @@ -162,7 +162,7 @@ class ChatStore { return ChatStore._(database, clock: clock, createId: createId); } - static const schemaVersion = 6; + static const schemaVersion = 7; // Keys are destination versions. Future upgrades register every successor // here and increase schemaVersion; no existing schema is dropped or replayed. @@ -174,6 +174,7 @@ class ChatStore { 4: _schemaV4, 5: _schemaV5, 6: _schemaV6, + 7: _schemaV7, }; final Database _db; final DateTime Function() _clock; @@ -822,6 +823,92 @@ class ChatStore { ); } + /// A group keeps its real host in chats.character_id. Library existence is + /// projected by the engine; the SQL roster never guesses a card by name. + TavernGroupChat? groupChat(String chatId) { + _ensureOpen(); + final rows = _db.select( + '''SELECT g.*, c.character_id, char(1) || c.title AS title + FROM group_chats g JOIN chats c ON c.id = g.chat_id WHERE g.chat_id = ?''', + [chatId], + ); + if (rows.isEmpty) return null; + final row = rows.single; + return TavernGroupChat( + chatId: chatId, + title: _unguardText(row['title'])!, + hostCharacterId: row['character_id'] as String, + activation: TavernGroupActivation.values.byName(row['activation'] as String), + maxSpeakersPerTurn: row['max_speakers'] as int, + allowSelfResponses: row['allow_self_responses'] == 1, + members: [ + for (final member in _db.select('SELECT * FROM group_members WHERE chat_id = ? ORDER BY position', [chatId])) + TavernGroupMember( + characterId: member['character_id'] as String, + talkativeness: (member['talkativeness'] as num).toDouble(), + muted: member['muted'] == 1, + modelRole: TavernGroupModelRole.values.byName(member['model_role'] as String), + ), + ], + ); + } + + void saveGroup(TavernGroupChat group) { + transaction(() { + final chat = _chatRow(group.chatId); + if (chat['character_id'] != group.hostCharacterId) throw StateError('A group host cannot be rebound.'); + _db.execute( + '''INSERT INTO group_chats (chat_id, activation, max_speakers, allow_self_responses) + VALUES (?, ?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET + activation = excluded.activation, max_speakers = excluded.max_speakers, + allow_self_responses = excluded.allow_self_responses''', + [group.chatId, group.activation.name, group.maxSpeakersPerTurn, group.allowSelfResponses ? 1 : 0], + ); + _db.execute('DELETE FROM group_members WHERE chat_id = ?', [group.chatId]); + for (var index = 0; index < group.members.length; index++) { + final member = group.members[index]; + _db.execute( + '''INSERT INTO group_members + (chat_id, character_id, position, talkativeness, muted, model_role) VALUES (?, ?, ?, ?, ?, ?)''', + [group.chatId, member.characterId, index, member.talkativeness, member.muted ? 1 : 0, member.modelRole.name], + ); + } + updateChat(group.chatId, title: group.title); + }); + } + + Map groupInitialState(String chatId) { + final rows = _db.select('SELECT initial_state FROM group_chats WHERE chat_id = ?', [chatId]); + return rows.isEmpty ? {} : _map(rows.single['initial_state']); + } + + void setGroupInitialState(String chatId, Map state) { + transaction(() { + if (groupChat(chatId) == null) throw StateError('Group not found: $chatId'); + _db.execute('UPDATE group_chats SET initial_state = ? WHERE chat_id = ?', [jsonEncode(state), chatId]); + _touch(chatId); + }); + } + + List groupHostChats(String characterId) => [ + for (final row in _db.select( + '''SELECT c.id FROM chats c JOIN group_chats g ON g.chat_id = c.id + WHERE c.character_id = ? ORDER BY c.id''', + [characterId], + )) + row['id'] as String, + ]; + + void removeCharacterGroupMemberships(String characterId) { + transaction(() { + final affected = _db.select('SELECT chat_id FROM group_members WHERE character_id = ?', [characterId]); + _db.execute('DELETE FROM group_members WHERE character_id = ?', [characterId]); + for (final row in affected) { + _touch(row['chat_id'] as String); + } + }); + } + /// Explicit persona bindings in the same recency order as [listChats]. /// Selection and cleanup do not need message bodies or chat JSON documents. List<({String id, String? personaId})> listChatPersonaBindings() { @@ -836,9 +923,10 @@ class ChatStore { Map characterChatStats() { _ensureOpen(); return { - for (final row in _db.select( - 'SELECT character_id, COUNT(*) AS chat_count, MAX(updated_at) AS last_at FROM chats GROUP BY character_id', - )) + for (final row in _db.select('''SELECT ids.character_id, COUNT(*) AS chat_count, MAX(c.updated_at) AS last_at + FROM (SELECT id AS chat_id, character_id FROM chats + UNION SELECT chat_id, character_id FROM group_members) ids + JOIN chats c ON c.id = ids.chat_id GROUP BY ids.character_id''')) row['character_id'] as String: (count: row['chat_count'] as int, lastAt: _date(row['last_at'])), }; } @@ -847,10 +935,13 @@ class ChatStore { _ensureOpen(); final rows = _db.select( '''SELECT c.id, c.character_id, char(1) || c.title AS title, c.updated_at, - char(1) || COALESCE(n.stream_text, n.text) AS last_text, COALESCE(n.depth, 0) AS message_count + char(1) || COALESCE(n.stream_text, n.text) AS last_text, COALESCE(n.depth, 0) AS message_count, + EXISTS(SELECT 1 FROM group_chats g WHERE g.chat_id = c.id) AS is_group FROM chats c LEFT JOIN nodes n ON n.id = c.current_leaf_id AND n.deleted_by IS NULL - WHERE (? IS NULL OR c.character_id = ?) ORDER BY c.updated_at DESC, c.id''', - [characterId, characterId], + WHERE (? IS NULL OR c.character_id = ? OR EXISTS( + SELECT 1 FROM group_members m WHERE m.chat_id = c.id AND m.character_id = ?)) + ORDER BY c.updated_at DESC, c.id''', + [characterId, characterId, characterId], ); return [ for (final row in rows) @@ -861,6 +952,10 @@ class ChatStore { updatedAt: _date(row['updated_at']), lastText: _unguardText(row['last_text']), messageCount: row['message_count'] as int, + kind: row['is_group'] == 1 ? TavernChatKind.group : TavernChatKind.single, + memberCharacterIds: row['is_group'] == 1 + ? [for (final member in groupChat(row['id'] as String)!.members) member.characterId] + : const [], ), ]; } @@ -908,6 +1003,7 @@ class ChatStore { required String text, String? id, String? reasoning, + String? speakerCharacterId, DateTime? at, bool hidden = false, bool makeCurrent = true, @@ -923,8 +1019,8 @@ class ChatStore { final nodeId = id ?? _createId(); _db.execute( '''INSERT INTO nodes - (id, chat_id, parent_id, depth, role, speaker, text, reasoning, at, hidden, usage, reported_usage, extra, original) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', + (id, chat_id, parent_id, depth, role, speaker, text, reasoning, at, hidden, usage, reported_usage, extra, original, speaker_character_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', [ nodeId, chatId, @@ -940,6 +1036,7 @@ class ChatStore { _reportedUsage(extra, chatId, nodeId), jsonEncode(extra), jsonEncode(original), + speakerCharacterId, ], ); final parentSnapshot = parentId == null ? null : _nodeStateSnapshot(chatId, parentId); @@ -1194,7 +1291,9 @@ ORDER BY n.seq String nodeId, { String? text, String? reasoning, + String? speakerCharacterId, TavernUsage? usage, + bool clearSpeakerCharacterId = false, bool clearReasoning = false, bool clearUsage = false, Map? extra, @@ -1214,6 +1313,9 @@ ORDER BY n.seq // UPDATE OF text drives the FTS trigger. Metadata-only writes must not // name text, and must not promote an in-progress body as a side effect. if (text != null || promote) put('text', text ?? _unguardText(row['stream_text'])); + if (speakerCharacterId != null || clearSpeakerCharacterId) { + put('speaker_character_id', clearSpeakerCharacterId ? null : speakerCharacterId); + } if (reasoning != null || clearReasoning || promote) { put( 'reasoning', @@ -1509,8 +1611,16 @@ ORDER BY n.seq void deleteCharacterChats(String characterId) { transaction(() { - final rows = _db.select('SELECT id FROM chats WHERE character_id = ?', [characterId]); - _db.execute('DELETE FROM chats WHERE character_id = ?', [characterId]); + final rows = _db.select( + '''SELECT id FROM chats WHERE character_id = ? + AND NOT EXISTS(SELECT 1 FROM group_chats g WHERE g.chat_id = chats.id)''', + [characterId], + ); + _db.execute( + '''DELETE FROM chats WHERE character_id = ? + AND NOT EXISTS(SELECT 1 FROM group_chats g WHERE g.chat_id = chats.id)''', + [characterId], + ); _changedChats.addAll(rows.map((row) => row['id'] as String)); }); } @@ -1795,6 +1905,7 @@ ORDER BY n.seq parentId: row['parent_id'] as String?, role: TavernRole.values.byName(row['role'] as String), speaker: _unguardText(row['speaker'])!, + speakerCharacterId: _unguardText(row['speaker_character_id']), text: _unguardText(row['stream_text'] ?? row['text'])!, reasoning: _unguardText(row['stream_text'] != null ? row['stream_reasoning'] : row['reasoning']), at: _date(row['at']), @@ -1891,7 +2002,7 @@ ORDER BY n.seq char(1) || n.speaker AS speaker, char(1) || n.text AS text, char(1) || n.reasoning AS reasoning, n.at, n.hidden, n.usage, n.extra, n.original, char(1) || n.stream_text AS stream_text, char(1) || n.stream_reasoning AS stream_reasoning, - n.deleted_by, n.reported_usage'''; + n.deleted_by, n.reported_usage, char(1) || n.speaker_character_id AS speaker_character_id'''; static const _chatColumns = ''' c.id, c.character_id, char(1) || c.title AS title, c.created_at, c.updated_at, @@ -1979,6 +2090,27 @@ CREATE TRIGGER nodes_fts_update AFTER UPDATE OF text, speaker ON nodes BEGIN INSERT INTO nodes_fts(nodes_fts, rowid, text, speaker) VALUES ('delete', old.seq, old.text, old.speaker); INSERT INTO nodes_fts(rowid, text, speaker) VALUES (new.seq, new.text, new.speaker); END; +'''; + + static const _schemaV7 = ''' +ALTER TABLE nodes ADD COLUMN speaker_character_id TEXT; +CREATE TABLE group_chats ( + chat_id TEXT PRIMARY KEY NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + activation TEXT NOT NULL, + max_speakers INTEGER NOT NULL CHECK(max_speakers > 0), + allow_self_responses INTEGER NOT NULL CHECK(allow_self_responses IN (0, 1)), + initial_state TEXT NOT NULL DEFAULT '{}' +); +CREATE TABLE group_members ( + chat_id TEXT NOT NULL REFERENCES group_chats(chat_id) ON DELETE CASCADE, + character_id TEXT NOT NULL, + position INTEGER NOT NULL, + talkativeness REAL NOT NULL CHECK(talkativeness >= 0 AND talkativeness <= 1), + muted INTEGER NOT NULL CHECK(muted IN (0, 1)), + model_role TEXT NOT NULL, + PRIMARY KEY(chat_id, character_id) +); +CREATE INDEX group_members_character ON group_members(character_id); '''; static const _schemaV6 = ''' diff --git a/packages/dsh_tavern/test/chat_jsonl_test.dart b/packages/dsh_tavern/test/chat_jsonl_test.dart index 5508f517af..ab8c1cd495 100644 --- a/packages/dsh_tavern/test/chat_jsonl_test.dart +++ b/packages/dsh_tavern/test/chat_jsonl_test.dart @@ -1246,6 +1246,85 @@ void main() { expect(_tree(store, result.chatIds.single), expected); }); + test('speaker identities omit null carriers and cannot revive cleared selected swipes', () { + Map extra(String id, int marker) => { + 'vendor': [marker, null], + 'dshgo': { + 'outerUnknown': false, + 'chatJsonl': { + 'version': 1, + 'speakerCharacterId': id, + 'future': {'keep': marker}, + }, + }, + }; + Map identity(Object? value) => _map(_map(_map(value)['dshgo'])['chatJsonl']); + final chat = load([ + _document('speaker-identities.jsonl', [ + {..._row('User', user: true), 'extra': extra('ignored-user-id', 1)}, + { + ..._row('Selected', swipes: ['Inactive', 'Selected'], selected: 1), + 'extra': extra('selected-id', 3), + 'swipe_info': [ + {'send_date': _time.toIso8601String(), 'extra': extra('inactive-id', 2)}, + {'send_date': _time.toIso8601String(), 'extra': extra('selected-id', 3)}, + ], + }, + ]), + ]); + final path = store.path(chat); + expect(path.map((message) => message.speakerCharacterId), [null, 'selected-id']); + expect(store.children(chat, path.first.nodeId).map((node) => node.message.speakerCharacterId), [ + 'inactive-id', + 'selected-id', + ]); + final first = codec.exportChat(store, chat).single; + expect(identity(first.messages.first['extra']).containsKey('speakerCharacterId'), isFalse); + expect(identity(first.messages.last['extra'])['speakerCharacterId'], 'selected-id'); + final initialInfos = first.messages.last['swipe_info']! as List; + expect(initialInfos.map((info) => identity(_map(info)['extra'])['speakerCharacterId']), [ + 'inactive-id', + 'selected-id', + ]); + expect(_map(first.messages.first['extra'])['vendor'], [1, null]); + expect(_map(_map(first.messages.first['extra'])['dshgo'])['outerUnknown'], isFalse); + expect(identity(first.messages.first['extra'])['future'], {'keep': 1}); + + store.updateMessage(chat, path.last.nodeId, clearSpeakerCharacterId: true); + expect(store.message(chat, path.last.nodeId).speakerCharacterId, isNull); + expect( + identity(store.node(chat, path.last.nodeId).extra)['speakerCharacterId'], + 'selected-id', + reason: 'The real canonical field is cleared while imported provenance still has its former value.', + ); + final clearedFiles = codec.exportChat(store, chat); + final cleared = clearedFiles.single; + expect(identity(cleared.messages.first['extra']).containsKey('speakerCharacterId'), isFalse); + expect(identity(cleared.messages.last['extra']).containsKey('speakerCharacterId'), isFalse); + final clearedInfos = cleared.messages.last['swipe_info']! as List; + expect(identity(_map(clearedInfos.first)['extra'])['speakerCharacterId'], 'inactive-id'); + expect(identity(_map(clearedInfos.last)['extra']).containsKey('speakerCharacterId'), isFalse); + expect(identity(_map(clearedInfos.first)['extra'])['future'], {'keep': 2}); + expect(identity(cleared.messages.last['extra'])['future'], {'keep': 3}); + expect(_map(cleared.messages.last['extra'])['vendor'], [3, null]); + expect( + identity(store.node(chat, path.last.nodeId).extra)['speakerCharacterId'], + 'selected-id', + reason: 'Export must not mutate stored provenance.', + ); + + final copy = load(clearedFiles); + final copiedPath = store.path(copy); + expect(copiedPath.map((message) => message.speakerCharacterId), [null, null]); + expect(store.children(copy, copiedPath.first.nodeId).map((node) => node.message.speakerCharacterId), [ + 'inactive-id', + null, + ]); + final again = codec.exportChat(store, copy).single; + expect(identity(again.messages.last['extra']).containsKey('speakerCharacterId'), isFalse); + expect(identity(again.messages.last['extra'])['future'], {'keep': 3}); + }); + test('a 2005-node chain imports and exports without recursive tree traversal', () { final chat = load([ _document('long', [for (var index = 0; index < 2005; index++) _row('line $index')]), diff --git a/packages/dsh_tavern/test/p2_group_store_engine_test.dart b/packages/dsh_tavern/test/p2_group_store_engine_test.dart new file mode 100644 index 0000000000..66c503527d --- /dev/null +++ b/packages/dsh_tavern/test/p2_group_store_engine_test.dart @@ -0,0 +1,609 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:test/test.dart'; + +// Real engine, card files, SQLite, JSONL and backup. No model result is supplied: +// these operations must never invoke the model or request an API key. +void main() { + test('group CRUD keeps duplicate names distinct and persists roster preferences', () async { + final h = await _Fixture.open(); + final first = await h.card(name: 'Twin', greeting: 'first greeting', talk: 0.2); + final second = await h.card(name: 'Twin', greeting: 'second greeting', talk: 0.8); + final third = await h.card(name: 'Third', greeting: 'must not be appended', talk: 5); + expect(first, isNot(second)); + final Tavern tavern = h.engine; + final id = await tavern.createGroupChat([first, second], title: 'Twins'); + final initial = (await tavern.watchGroupChat(id).first)!; + expect(initial.hostCharacterId, first); + expect(initial.activation, TavernGroupActivation.natural); + expect(initial.maxSpeakersPerTurn, 3); + expect(initial.allowSelfResponses, isFalse); + expect(_members(initial), [ + [first, 0.2, false, TavernGroupModelRole.main, true], + [second, 0.8, false, TavernGroupModelRole.helper, true], + ]); + expect(() => initial.members.clear(), throwsUnsupportedError); + final originalPath = await tavern.path(id); + expect(originalPath.map((m) => m.text), ['first greeting', 'second greeting']); + expect(originalPath.map((m) => m.speaker), ['Twin', 'Twin']); + expect(originalPath.map((m) => m.speakerCharacterId), [first, second]); + expect(originalPath.last.parentId, originalPath.first.nodeId); + + final added = await tavern.addGroupMember(id, third, muted: true); + expect(_members(added).last, [third, 1.0, true, TavernGroupModelRole.helper, true]); + final changed = await tavern.updateGroupMember( + id, + second, + talkativeness: 0.35, + muted: true, + modelRole: TavernGroupModelRole.main, + ); + expect(_members(changed)[1], [second, 0.35, true, TavernGroupModelRole.main, true]); + expect((await tavern.groupChat(id))!.members.first.talkativeness, 0.2); + expect(initial.members[1].muted, isFalse); + await tavern.reorderGroupMembers(id, [third, second, first]); + final configured = await tavern.updateGroupChat( + id, + title: 'Saved group', + activation: TavernGroupActivation.listOrder, + maxSpeakersPerTurn: 2, + allowSelfResponses: true, + ); + expect(configured.members.map((m) => m.characterId), [third, second, first]); + final removed = await tavern.removeGroupMember(id, first); + expect(removed.hostCharacterId, first); + expect(removed.members.map((m) => m.characterId), [third, second]); + expect((await tavern.path(id)).map((m) => m.nodeId), originalPath.map((m) => m.nodeId)); + + for (final characterId in [first, second, third]) { + final chats = await tavern.watchChats(characterId: characterId).first; + expect(chats.map((chat) => chat.id), [id]); + expect(chats.single.kind, TavernChatKind.group); + expect(chats.single.characterId, first); + expect(chats.single.memberCharacterIds, [third, second]); + expect(chats.single.messageCount, 2); + expect((await tavern.character(characterId))!.chatCount, 1); + } + final saved = _configuration(removed); + await h.reopen(); + expect(_configuration((await h.engine.groupChat(id))!), saved); + expect((await h.engine.path(id)).map((m) => m.speakerCharacterId), [first, second]); + final deleted = h.engine.watchGroupChat(id).firstWhere((group) => group == null); + await h.engine.deleteChat(id); + expect(await deleted, isNull); + expect(await h.engine.groupChat(id), isNull); + expect(await h.engine.watchChats().first, isEmpty); + h.expectNoGeneration(); + }); + + test('invalid group edits fail without changing the saved roster or creating chats', () async { + final h = await _Fixture.open(); + final host = await h.card(name: 'Host'); + final member = await h.card(name: 'Member', talk: 'NaN'); + final id = await h.engine.createGroupChat([host, member], addGreetings: false); + expect((await h.engine.groupChat(id))!.members[1].talkativeness, 0.5); + final before = _configuration((await h.engine.groupChat(id))!); + await expectLater(h.engine.createGroupChat([host, host]), throwsA(_groupError(TavernGroupError.invalidMembers))); + await expectLater(h.engine.createGroupChat([]), throwsA(_groupError(TavernGroupError.invalidMembers))); + await expectLater( + h.engine.createGroupChat([host, 'missing-card']), + throwsA(_groupError(TavernGroupError.characterNotFound)), + ); + await expectLater( + h.engine.createGroupChat([host], maxSpeakersPerTurn: 0), + throwsA(_groupError(TavernGroupError.invalidLimit)), + ); + await expectLater(h.engine.addGroupMember(id, member), throwsA(_groupError(TavernGroupError.memberExists))); + await expectLater( + h.engine.removeGroupMember(id, 'missing-member'), + throwsA(_groupError(TavernGroupError.memberNotFound)), + ); + for (final value in [double.nan, double.infinity, -0.1, 1.1]) { + await expectLater( + h.engine.updateGroupMember(id, member, talkativeness: value), + throwsA(_groupError(TavernGroupError.invalidTalkativeness)), + ); + } + for (final order in [ + [host], + [host, host], + [host, 'missing-member'], + ]) { + await expectLater(h.engine.reorderGroupMembers(id, order), throwsA(_groupError(TavernGroupError.invalidOrder))); + } + await expectLater( + h.engine.updateGroupChat(id, maxSpeakersPerTurn: 0), + throwsA(_groupError(TavernGroupError.invalidLimit)), + ); + expect(_configuration((await h.engine.groupChat(id))!), before); + expect((await h.engine.watchChats().first).map((chat) => chat.id), [id]); + expect(await h.engine.path(id), isEmpty); + final single = await h.engine.createChat(host); + expect(await h.engine.groupChat(single), isNull); + expect(await h.engine.groupChat('missing-chat'), isNull); + await expectLater( + h.engine.updateGroupChat(single, title: 'not a group'), + throwsA(_groupError(TavernGroupError.notGroup)), + ); + await h.reopen(); + expect(_configuration((await h.engine.groupChat(id))!), before); + h.expectNoGeneration(); + }); + + test('host deletion is guarded before card and single-chat removal while actors may leave', () async { + final h = await _Fixture.open(); + final host = await h.card(name: 'Host', greeting: 'host history'); + final actor = await h.card(name: 'Actor', greeting: 'actor history'); + final hostSingle = await h.engine.createChat(host); + final actorSingle = await h.engine.createChat(actor); + final group = await h.engine.createGroupChat([host, actor]); + final empty = await h.engine.createGroupChat([], hostCharacterId: host, addGreetings: false); + final pathBefore = await h.engine.path(group); + final cardFile = File('${h.root.path}/characters/$host/card.json'); + final cardBefore = await cardFile.readAsBytes(); + await h.engine.removeGroupMember(group, host); + await expectLater( + h.engine.deleteCharacter(host), + throwsA( + _groupError(TavernGroupError.hostInUse) + .having((error) => error.characterId, 'characterId', host) + .having((error) => error.relatedChatIds, 'relatedChatIds', unorderedEquals([group, empty])), + ), + ); + expect(await cardFile.readAsBytes(), cardBefore); + expect(await h.engine.character(host), isNotNull); + expect((await h.engine.path(hostSingle)).single.text, 'host history'); + expect((await h.engine.groupChat(group))!.hostCharacterId, host); + + await h.engine.deleteCharacter(actor); + expect(await h.engine.character(actor), isNull); + expect(await Directory('${h.root.path}/characters/$actor').exists(), isFalse); + expect((await h.engine.groupChat(group))!.members, isEmpty); + expect((await h.engine.path(group)).map((m) => m.nodeId), pathBefore.map((m) => m.nodeId)); + expect((await h.engine.path(group)).map((m) => m.speakerCharacterId), [host, actor]); + expect((await h.engine.watchChats().first).map((chat) => chat.id), isNot(contains(actorSingle))); + await h.reopen(); + expect((await h.engine.path(group)).last.speakerCharacterId, actor); + expect((await h.engine.groupChat(empty))!.hostCharacterId, host); + await h.engine.deleteChat(group); + await h.engine.deleteChat(empty); + await h.engine.deleteCharacter(host); + expect(await h.engine.character(host), isNull); + expect(await h.engine.watchChats().first, isEmpty); + h.expectNoGeneration(); + }); + + test('a group without greetings saves the host initial state without a hidden seed node', () async { + final h = await _Fixture.open(); + final host = await h.card(name: 'Host', greeting: 'not added', initialCount: 7); + final actor = await h.card(name: 'Actor', greeting: 'not added either', initialCount: 99); + final id = await h.engine.createGroupChat([actor], hostCharacterId: host, addGreetings: false); + expect(await h.engine.path(id), isEmpty); + expect(await h.engine.tree(id), isEmpty); + final initial = await h.engine.readState(id); + expect(initial.legacy, isFalse); + expect(initial.initialized, isTrue); + expect(initial.valueExists, isTrue); + expect(initial.initialValuesAvailable, isFalse); + expect(initial.nodeId, isNull); + expect(initial.values, {'count': 7}); + expect(await h.engine.readAgentRuns(id), isEmpty); + // This fixture replaces the card source while the engine is closed. The + // public field editor intentionally does not edit extensions. A later card + // init must not replace the group's already saved host state on reopen. + await h.reopen( + whileClosed: () async { + final file = File('${h.root.path}/characters/$host/card.json'); + final raw = _map(jsonDecode(await file.readAsString())); + _map(raw['data'])['extensions'] = _stateExtension(42); + await file.writeAsString(jsonEncode(raw)); + }, + ); + expect((await h.engine.readState(id)).values, {'count': 7}); + expect(await h.engine.tree(id), isEmpty); + + // The actor has its own different initial value (99), while the changed + // host source now says 42. Neither may reset the saved value of 7. + final first = await h.engine.sendOnly(id, 'first user line'); + expect(first.role, TavernRole.user); + expect(first.speakerCharacterId, isNull); + expect((await h.engine.readState(id)).values, {'count': 7}); + final edited = await h.engine.setStateValue(id, '/count', 11, expectedNodeId: first.nodeId); + expect(edited.applied, isTrue); + expect(edited.snapshot.values, {'count': 11}); + await h.engine.addGroupMember(id, host); + await h.engine.reorderGroupMembers(id, [host, actor]); + await h.engine.removeGroupMember(id, actor); + await h.engine.sendOnly(id, 'second user line'); + await h.reopen(); + expect((await h.engine.readState(id)).values, {'count': 11}); + expect((await h.engine.path(id)).map((m) => m.text), ['first user line', 'second user line']); + expect((await h.engine.tree(id)).length, 2); + h.expectNoGeneration(); + }); + + test('speaker identities survive same-name JSONL export and import without inventing avatars', () async { + final h = await _Fixture.open(); + final first = await h.card(name: 'Twin', greeting: 'first body'); + final second = await h.card(name: 'Twin', greeting: 'second body'); + final id = await h.engine.createGroupChat([first, second]); + await h.engine.sendOnly(id, 'user body'); + await h.engine.narrate(id, 'narrator body', speaker: 'Twin'); + await h.engine.updateCharacterFields(second, {'name': 'Renamed actor'}); + await h.engine.removeGroupMember(id, second); + final before = await h.engine.path(id); + expect(before.map((m) => m.speakerCharacterId), [first, second, null, null]); + expect((await h.engine.tree(id)).map((m) => m.speakerCharacterId), [first, second, null, null]); + expect((await h.engine.searchChat(id, 'second')).single.speakerCharacterId, second); + + final files = await h.engine.exportChat(id); + final document = StChatDocument.decode(files.first.fileName, files.first.bytes); + expect(document.messages.map((line) => line['name']), ['Twin', 'Twin', before[2].speaker, 'Twin']); + for (var index = 0; index < 2; index++) { + final row = document.messages[index]; + final own = _map(_map(_map(row['extra'])['dshgo'])['chatJsonl']); + final identity = index == 0 ? first : second; + expect(own['speakerCharacterId'], identity); + expect(row['original_avatar'], isNot(identity)); + } + final imported = await h.engine.importChats(first, files); + final copy = imported.chatIds.single; + expect( + await h.engine.groupChat(copy), + isNull, + reason: 'JSONL carries messages, not a cross-library group definition.', + ); + expect((await h.engine.path(copy)).map((m) => m.text), before.map((m) => m.text)); + expect((await h.engine.path(copy)).map((m) => m.speakerCharacterId), [first, second, null, null]); + + final external = StChatDocument( + fileName: 'unmapped.jsonl', + header: {'user_name': 'User', 'character_name': 'Twin', 'chat_metadata': {}}, + messages: [ + { + 'name': 'Twin', + 'is_user': false, + 'is_system': false, + 'mes': 'external body', + 'send_date': '2026-11-12T04:00:00.000Z', + 'original_avatar': 'unmapped.png', + }, + ], + ); + final unknown = (await h.engine.importChats(first, [ + TavernChatFile(fileName: external.fileName, bytes: external.encode()), + ])).chatIds.single; + expect((await h.engine.path(unknown)).single.speakerCharacterId, isNull); + await h.reopen(); + expect((await h.engine.path(copy)).map((m) => m.speakerCharacterId), [first, second, null, null]); + expect((await h.engine.path(unknown)).single.speakerCharacterId, isNull); + final unknownFiles = await h.engine.exportChat(unknown); + final unknownDocument = StChatDocument.decode(unknownFiles.first.fileName, unknownFiles.first.bytes); + expect(unknownDocument.messages.single['original_avatar'], 'unmapped.png'); + h.expectNoGeneration(); + }); + + test('whole-directory backup restores group configuration and an empty host state', () async { + final h = await _Fixture.open(); + final host = await h.card(name: 'Host', greeting: 'host body', initialCount: 7); + final actor = await h.card(name: 'Actor', greeting: 'actor body'); + final group = await h.engine.createGroupChat( + [actor, host], + hostCharacterId: host, + title: 'Backed up', + activation: TavernGroupActivation.listOrder, + maxSpeakersPerTurn: 1, + allowSelfResponses: true, + ); + await h.engine.updateGroupMember( + group, + actor, + talkativeness: 0.3, + muted: true, + modelRole: TavernGroupModelRole.main, + ); + final empty = await h.engine.createGroupChat( + [], + hostCharacterId: host, + title: 'Empty but initialized', + addGreetings: false, + ); + final configuration = _configuration((await h.engine.groupChat(group))!); + final path = await h.engine.path(group); + final work = await Directory.systemTemp.createTemp('dsh-p2-group-backup-'); + TavernEngine? restored; + addTearDown(() async { + try { + await restored?.close(); + } finally { + if (await work.exists()) await work.delete(recursive: true); + } + }); + final archive = File('${work.path}/groups.zip'); + final info = await h.engine.backup(archive.path); + expect(info.sqliteUserVersion, 7); + final destination = Directory('${work.path}/restored'); + final restoredInfo = await restoreTavernDirectory(archive.path, destination.path); + expect(restoredInfo.sqliteUserVersion, 7); + restored = await TavernEngine.openDirectory( + destination, + config: h.config, + modelClient: h.model, + clock: () => _at, + random: () => 0.25, + ); + final engine = restored; + expect(_configuration((await engine.groupChat(group))!), configuration); + expect((await engine.path(group)).map((m) => m.nodeId), path.map((m) => m.nodeId)); + expect((await engine.path(group)).map((m) => m.speakerCharacterId), [actor, host]); + expect((await engine.character(host))!.name, 'Host'); + expect((await engine.groupChat(empty))!.members, isEmpty); + expect(await engine.tree(empty), isEmpty); + expect((await engine.readState(empty)).values, {'count': 7}); + final manual = await engine.sendOnly(empty, 'restored group remains writable'); + expect(manual.speakerCharacterId, isNull); + expect((await engine.readState(empty)).values, {'count': 7}); + h.expectNoGeneration(); + }); + + test('genuine schema six migrates once preserving old data and accepts new groups', () async { + final h = await _Fixture.open(beforeOpen: _writeVersionSix); + expect((await h.engine.watchChats().first).single.kind, TavernChatKind.single); + expect(await h.engine.groupChat('old-chat'), isNull); + final old = (await h.engine.path('old-chat')).single; + expect(old.nodeId, 'old-node'); + expect(old.text, 'old searchable oracle'); + expect(old.speakerCharacterId, isNull); + expect(old.reportedUsage!.promptTokens, 17); + expect((await h.engine.tree('old-chat')).single.speakerCharacterId, isNull); + expect(await h.engine.readVariables('old-chat'), {'kept': 7}); + expect((await h.engine.searchChat('old-chat', 'searchable')).single.nodeId, 'old-node'); + final snapshot = _inspect( + h.root, + (db) => { + 'version': db.select('PRAGMA user_version').single['user_version'], + 'marker': db.select('SELECT vendor_marker FROM chats WHERE id = ?', ['old-chat']).single['vendor_marker'], + 'metadata': jsonDecode( + db.select('SELECT metadata FROM chats WHERE id = ?', ['old-chat']).single['metadata']! as String, + ), + }, + ); + expect(snapshot, { + 'version': 7, + 'marker': 'do not rewrite', + 'metadata': { + 'foreign': ['keep', 2], + }, + }); + final host = await h.card(name: 'New host', greeting: 'new group greeting'); + final group = await h.engine.createGroupChat([host]); + expect((await h.engine.groupChat(group))!.hostCharacterId, host); + expect((await h.engine.path(group)).single.speakerCharacterId, host); + await h.reopen(); + expect((await h.engine.path('old-chat')).single.nodeId, 'old-node'); + expect(await h.engine.readVariables('old-chat'), {'kept': 7}); + expect((await h.engine.path(group)).single.speakerCharacterId, host); + expect(_inspect(h.root, (db) => db.select('PRAGMA user_version').single['user_version']), 7); + h.expectNoGeneration(); + }); +} + +final _at = DateTime.utc(2026, 11, 12, 4); + +class _Fixture { + _Fixture(this.root); + + static Future<_Fixture> open({void Function(Directory)? beforeOpen}) async { + final root = await Directory.systemTemp.createTemp('dsh-p2-group-store-'); + final fixture = _Fixture(root); + // Registered before the first database open, including fixture preparation. + addTearDown(fixture.dispose); + beforeOpen?.call(root); + await fixture._open(); + return fixture; + } + + final Directory root; + final model = _UnexpectedModel(); + TavernEngine? _engine; + int keyCalls = 0; + late final config = TavernModelConfig( + apiKey: () async { + keyCalls++; + throw StateError('Storage operations must not request a model key.'); + }, + model: 'unused-group-storage', + baseUrl: 'https://group-storage.invalid/v1', + deepseek: false, + ); + + TavernEngine get engine => _engine!; + + Future _open() async { + _engine = await TavernEngine.openDirectory( + root, + config: config, + modelClient: model, + clock: () => _at, + random: () => 0.25, + ); + } + + Future reopen({Future Function()? whileClosed}) async { + await engine.close(); + _engine = null; + await whileClosed?.call(); + await _open(); + } + + Future card({required String name, String greeting = '', Object? talk, int? initialCount}) async { + final card = await engine.importCard( + Uint8List.fromList( + utf8.encode( + jsonEncode({ + 'spec': 'chara_card_v3', + 'spec_version': '3.0', + 'data': { + 'name': name, + 'description': '', + 'personality': '', + 'scenario': '', + 'first_mes': greeting, + 'mes_example': '', + 'creator_notes': '', + 'system_prompt': '', + 'post_history_instructions': '', + 'alternate_greetings': [], + 'tags': [], + 'creator': '', + 'character_version': '', + 'talkativeness': ?talk, + 'extensions': initialCount == null ? {} : _stateExtension(initialCount), + }, + }), + ), + ), + ); + return card.id; + } + + void expectNoGeneration() { + expect(model.calls, 0); + expect(keyCalls, 0); + } + + Future dispose() async { + try { + await _engine?.close(); + } finally { + if (await root.exists()) await root.delete(recursive: true); + } + } +} + +class _UnexpectedModel extends TavernModelClient { + int calls = 0; + + @override + Stream generate({ + required TavernModelConfig config, + required Map body, + required ModelCancellation cancellation, + }) { + calls++; + throw StateError('Storage operations must not generate.'); + } +} + +Map _stateExtension(int count) => { + 'dshgo': { + 'v': 1, + 'state': { + 'schema': { + 'type': 'object', + 'additionalProperties': false, + 'required': ['count'], + 'properties': { + 'count': {'type': 'integer', 'minimum': 0, 'maximum': 100, 'default': 0}, + }, + }, + 'init': {'count': count}, + 'rules': [], + }, + }, +}; + +Map _map(Object? value) => value! as Map; + +List> _members(TavernGroupChat group) => [ + for (final member in group.members) + [member.characterId, member.talkativeness, member.muted, member.modelRole, member.available], +]; + +Map _configuration(TavernGroupChat group) => { + 'id': group.chatId, + 'title': group.title, + 'host': group.hostCharacterId, + 'members': _members(group), + 'activation': group.activation, + 'maxSpeakersPerTurn': group.maxSpeakersPerTurn, + 'allowSelfResponses': group.allowSelfResponses, +}; + +TypeMatcher _groupError(TavernGroupError code) => + isA().having((error) => error.code, 'code', code); + +T _inspect(Directory root, T Function(Database) action) { + final db = sqlite3.open('${root.path}/tavern.sqlite3', mode: OpenMode.readOnly); + try { + return action(db); + } finally { + db.close(); + } +} + +void _writeVersionSix(Directory root) { + final db = sqlite3.open('${root.path}/tavern.sqlite3'); + try { + // Genuine frozen v4 SQL plus the fixed v5/v6 DDL, before inserting data. + // There are no rows for the v5 reported-usage backfill to transform. This + // does not create a current database and relabel its version as old. + db.execute(File('test/fixtures/t24_schema_v4.sql').readAsStringSync()); + db.execute(_versionFiveAndSix); + expect(db.select('PRAGMA user_version').single['user_version'], 6); + expect(db.select('PRAGMA table_info(nodes)').map((row) => row['name']), isNot(contains('speaker_character_id'))); + db.execute("ALTER TABLE chats ADD COLUMN vendor_marker TEXT DEFAULT 'do not rewrite'"); + db.execute( + '''INSERT INTO chats(id,character_id,title,created_at,updated_at,metadata,variables) + VALUES('old-chat','old-card','Old single',1,2,?,?)''', + [ + jsonEncode({ + 'foreign': ['keep', 2], + }), + jsonEncode({'kept': 7}), + ], + ); + db.execute( + '''INSERT INTO nodes(id,chat_id,depth,role,speaker,text,at,reported_usage) + VALUES('old-node','old-chat',1,'character','Old actor','old searchable oracle',1,?)''', + [ + jsonEncode({'promptTokens': 17, 'cacheHitTokens': 0, 'completionTokens': 3, 'model': 'old'}), + ], + ); + db.execute("INSERT INTO node_states(node_id,full_json) VALUES('old-node',?)", [ + jsonEncode({ + 'variables': {'kept': 7}, + }), + ]); + db.execute("UPDATE chats SET selected_root_id='old-node',current_leaf_id='old-node' WHERE id='old-chat'"); + } finally { + db.close(); + } +} + +// Schema 5 and 6 add these objects to the frozen empty v4 fixture. Keep this +// input independent of ChatStore.schemaVersion and its current migration map. +const _versionFiveAndSix = ''' +ALTER TABLE chats ADD COLUMN tree_revision INTEGER NOT NULL DEFAULT 0; +CREATE TABLE deleted_subtrees ( + id TEXT PRIMARY KEY NOT NULL, + chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + root_node_id TEXT NOT NULL, + parent_id TEXT, + selected_child_id TEXT, + selected_root_id TEXT, + current_leaf_id TEXT, + revision_after INTEGER NOT NULL +); +CREATE INDEX deleted_subtrees_chat ON deleted_subtrees(chat_id); +ALTER TABLE nodes ADD COLUMN deleted_by TEXT REFERENCES deleted_subtrees(id) DEFERRABLE INITIALLY DEFERRED; +CREATE INDEX nodes_deleted_by ON nodes(deleted_by) WHERE deleted_by IS NOT NULL; +ALTER TABLE nodes ADD COLUMN reported_usage TEXT; +CREATE INDEX agent_runs_chat_kind ON agent_runs(chat_id, kind); +PRAGMA user_version = 6; +'''; diff --git a/packages/dsh_tavern/test/p2_group_turn_engine_test.dart b/packages/dsh_tavern/test/p2_group_turn_engine_test.dart new file mode 100644 index 0000000000..8c6e75cb2a --- /dev/null +++ b/packages/dsh_tavern/test/p2_group_turn_engine_test.dart @@ -0,0 +1,1075 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:http/http.dart' as http; +import 'package:sqlite3/sqlite3.dart'; +import 'package:test/test.dart'; + +import 'support/t24_engine_harness.dart' show t24Preset; + +typedef _Json = Map; +typedef _Request = ({String method, Uri url, _Json body}); + +// These are new public group-API tests, not old-side probes. The production +// encoder, response decoder, engine, files and SQLite are used throughout. +// Only the HTTP peer, clock and random source are synthetic. +void main() { + test('group calls use actor cards and all member books with one durable shared path', () async { + final h = await _Harness.open(); + final host = await h.card('Host', description: 'HOST_NOT_AN_ACTOR'); + final a = await h.card('Alpha', description: 'DESC_A', book: 'WI_A', order: 100, replacement: 'A_SAVED'); + final b = await h.card('Beta', description: 'DESC_B', book: 'WI_B', order: 99, replacement: 'B_SAVED'); + final muted = await h.card('Muted', description: 'MUTED_NOT_AN_ACTOR', book: 'WI_M', order: 98); + final chat = await h.engine.createGroupChat( + [a, b, muted], + hostCharacterId: host, + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + await h.engine.updateGroupMember(chat, a, modelRole: TavernGroupModelRole.main); + await h.engine.updateGroupMember(chat, muted, muted: true); + await h.engine.setPreset(_preset(controls: true)); + expect(await h.engine.path(chat), isEmpty); + await h.reopen(); + expect((await h.engine.groupChat(chat))!.members.map((member) => member.characterId), [a, b, muted]); + expect(h.requests, isEmpty); + + final wrongApi = await h.engine.send(chat, 'not inserted').toList(); + expect(wrongApi.whereType(), isEmpty); + expect(wrongApi.whereType().single.kind, TavernFailure.invalidParameters); + expect(wrongApi.whereType().single.detail, 'group_requires_sendGroup'); + expect(await h.engine.path(chat), isEmpty); + final first = h.json('RAW'); + final second = h.json('RAW'); + final savedBeforeSecond = <(String, String, String?)>[]; + second.beforeResponse = () async { + // A separate SQLite reader cannot see merely buffered A text. The + // current B staging row may already exist with an empty body. + final db = sqlite3.open('${h.root.path}/tavern.sqlite3'); + try { + savedBeforeSecond.addAll( + db + .select( + 'SELECT role, text, speaker_character_id FROM nodes WHERE chat_id = ? AND text <> ? ORDER BY depth', + [chat, ''], + ) + .map((row) => (row['role']! as String, row['text']! as String, row['speaker_character_id'] as String?)), + ); + } finally { + db.close(); + } + }; + stdout.writeln('P2_GROUP_TURN_READY'); + final events = await h.engine.sendGroup(chat, 'Q').toList(); + final expectedA = _body([ + {'role': 'system', 'content': 'WI_M\nWI_B\nWI_A'}, + {'role': 'system', 'content': 'MAIN Alpha'}, + {'role': 'system', 'content': 'DESC_A'}, + {'role': 'system', 'content': 'NEW Alpha, Beta, Muted'}, + {'role': 'user', 'content': 'Q'}, + {'role': 'system', 'content': 'NEXT Alpha;ALL Alpha, Beta, Muted;ACTIVE Alpha, Beta'}, + ]); + final expectedB = _body([ + {'role': 'system', 'content': 'WI_M\nWI_B\nWI_A'}, + {'role': 'system', 'content': 'MAIN Beta'}, + {'role': 'system', 'content': 'DESC_B'}, + {'role': 'system', 'content': 'NEW Alpha, Beta, Muted'}, + {'role': 'user', 'content': 'Q'}, + {'role': 'assistant', 'content': 'Alpha: A_SAVED'}, + {'role': 'system', 'content': 'NEXT Beta;ALL Alpha, Beta, Muted;ACTIVE Alpha, Beta'}, + ]); + // Two complete independently specified wire bodies, not marker-only checks. + expect(h.requests.map((request) => request.body), [expectedA, expectedB]); + expect((first.sends, second.sends, h.keyReads), (1, 1, 2)); + expect(savedBeforeSecond, [('user', 'Q', null), ('character', 'A_SAVED', a)]); + expect(h.requests.map((request) => (request.method, request.url)), [ + for (var index = 0; index < 2; index++) ('POST', Uri.parse('https://p2-group.invalid/v1/chat/completions')), + ]); + final finished = events.whereType().single; + expect(finished.reason, TavernGroupEndReason.completed); + expect(finished.failure, isNull); + final done = _done(events); + expect(done.map((row) => (row.speaker, row.speakerCharacterId, row.text)), [ + ('Alpha', a, 'A_SAVED'), + ('Beta', b, 'B_SAVED'), + ]); + expect(events.whereType().map((event) => event.characterId).toSet(), {a, b}); + expect(finished.progress!.plannedCharacterIds, [a, b]); + expect(finished.progress!.remainingCharacterIds, isEmpty); + expect(finished.progress!.completedNodeIds, done.map((row) => row.nodeId)); + final speaking = events + .whereType() + .map((event) => event.progress) + .where((progress) => progress.phase == TavernGroupPhase.speaking) + .toList(); + expect(speaking.map((progress) => progress.speakerCharacterId), [a, b]); + expect(speaking.map((progress) => progress.remainingCharacterIds), [ + [b], + [], + ]); + expect(speaking.every((progress) => progress.generationId != null && progress.generationId!.isNotEmpty), isTrue); + expect(speaking.map((progress) => progress.generationId).toSet(), hasLength(2)); + final path = await h.engine.path(chat); + expect(path.map((row) => (row.role, row.text, row.speakerCharacterId)), [ + (TavernRole.user, 'Q', null), + (TavernRole.character, 'A_SAVED', a), + (TavernRole.character, 'B_SAVED', b), + ]); + expect(path.map((row) => row.parentId), [null, path[0].nodeId, path[1].nodeId]); + expect(finished.progress!.userNodeId, path[0].nodeId); + final identity = path.map((row) => (row.nodeId, row.parentId, row.text, row.speakerCharacterId)).toList(); + for (var index = 0; index < done.length; index++) { + final inspection = (await h.engine.inspectTurn(chat, nodeId: done[index].nodeId))!; + expect(inspection.status, TavernInspectionStatus.completed); + expect(inspection.prompt!.body, index == 0 ? expectedA : expectedB); + final hits = _maps(inspection.worldInfo['activated']); + expect(hits.map((hit) => hit['content']).toSet(), {'WI_A', 'WI_B', 'WI_M'}); + } + await h.reopen(); + expect( + (await h.engine.path(chat)).map((row) => (row.nodeId, row.parentId, row.text, row.speakerCharacterId)), + identity, + ); + expect(await h.engine.resumeGroupTurn(chat).toList(), isEmpty); + expect(h.requests.map((request) => request.body), [expectedA, expectedB]); + expect(h.keyReads, 2); + }); + + test('group streams detach and resume atomically while stop retains partial actor and skips the queue', () async { + final h = await _Harness.open(); + await h.engine.setPreset(_preset(streaming: true)); + final a = await h.card('Same'); + final b = await h.card('Same'); + final c = await h.card('Third'); + final chat = await h.engine.createGroupChat( + [a, b, c], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final first = h.stream(); + final second = h.stream(); + final initial = _Events(h.engine.sendGroup(chat, 'one user')); + await initial.waitRequest(first); + await first.listening.future; + first.delta({'content': 'alpha ', 'reasoning_content': 'private A'}); + await initial.until((event) => _hasDelta(event, a, 'alpha ')); + final firstRound = initial.values.whereType().single.progress.roundId; + await initial.detach(); + + final paused = _Events(h.engine.resumeGroupTurn(chat)); + final snap = await paused.until((event) => event is TavernGroupSnapshot) as TavernGroupSnapshot; + expect(snap.progress.roundId, firstRound); + expect(snap.progress.speakerCharacterId, a); + expect(snap.progress.remainingCharacterIds, [b, c]); + expect(snap.progress.nextCharacterId, b); + expect(snap.progress.remainingCount, 2); + expect((snap.message!.text, snap.message!.reasoning, snap.message!.speakerCharacterId), ('alpha ', 'private A', a)); + expect(h.requests, hasLength(1)); + paused.subscription.pause(); + + final resumed = _Events(h.engine.resumeGroupTurn(chat)); + final duplicate = await h.engine.sendGroup(chat, 'must not insert').toList(); + expect(duplicate.whereType().single.failure, TavernFailure.busy); + expect(duplicate.whereType().single.reason, TavernGroupEndReason.failed); + final oldSend = await h.engine.send(chat, 'also not inserted').toList(); + expect(oldSend.whereType().single.kind, TavernFailure.busy); + expect(h.requests, hasLength(1)); + first.finish(); + await resumed.waitRequest(second); + await second.listening.future; + expect(_done(resumed.values).single.speakerCharacterId, a); + expect(_maps(h.requests[1].body['messages']), [ + {'role': 'user', 'content': 'one user'}, + {'role': 'assistant', 'content': 'Same: alpha '}, + ]); + expect(jsonEncode(h.requests[1].body), isNot(contains('private A'))); + second.delta({'content': 'beta partial'}); + await resumed.until((event) => _hasDelta(event, b, 'beta partial')); + final bSnapshot = _Events(h.engine.resumeGroupTurn(chat)); + final bSnap = await bSnapshot.until((event) => event is TavernGroupSnapshot) as TavernGroupSnapshot; + expect(bSnap.progress.speakerCharacterId, b); + expect(bSnap.progress.remainingCharacterIds, [c]); + expect(bSnap.progress.completedNodeIds, [_done(resumed.values).single.nodeId]); + expect((bSnap.message!.text, bSnap.message!.speakerCharacterId), ('beta partial', b)); + expect(bSnap.progress.generationId, isNot(snap.progress.generationId)); + h.engine.stop(chat); + await resumed.done.future; + await bSnapshot.done.future; + final finish = resumed.values.whereType().single; + expect( + (finish.reason, finish.failure, finish.attemptedCharacterId), + (TavernGroupEndReason.cancelled, TavernFailure.cancelled, b), + ); + expect((finish.partial!.text, finish.partial!.speakerCharacterId), ('beta partial', b)); + expect(finish.progress!.remainingCharacterIds, [c]); + expect(finish.progress!.completedNodeIds, [_done(resumed.values).single.nodeId]); + expect( + resumed.values + .whereType() + .where((event) => event.event is TavernTurnFailed) + .single + .characterId, + b, + ); + expect(h.requests, hasLength(2)); + expect(h.keyReads, 2); + final path = await h.engine.path(chat); + expect(path.map((row) => (row.role, row.text, row.speakerCharacterId)), [ + (TavernRole.user, 'one user', null), + (TavernRole.character, 'alpha ', a), + (TavernRole.character, 'beta partial', b), + ]); + expect(path.map((row) => row.parentId), [null, path[0].nodeId, path[1].nodeId]); + final identities = path.map((row) => row.nodeId).toList(); + // A paused observer must not hold close, persistence or a new engine open. + await h.reopen(); + await paused.detach(); + expect((await h.engine.path(chat)).map((row) => row.nodeId), identities); + expect((await h.engine.path(chat)).last.speakerCharacterId, b); + expect(await h.engine.resumeGroupTurn(chat).toList(), isEmpty); + expect(h.requests, hasLength(2)); + expect(first.sends, 1); + expect(second.sends, 1); + }); + + test('manual list natural and pool select finite literal schedules without replaying random choices', () async { + var draws = 0; + final h = await _Harness.open( + random: () { + draws++; + return 0.75; + }, + ); + final a = await h.card('Alpha', talkativeness: 0); + final b = await h.card('Beta', talkativeness: 0); + final c = await h.card('Gamma', talkativeness: 0); + final d = await h.card('Delta', talkativeness: 0); + final chat = await h.engine.createGroupChat( + [a, b, c, d], + activation: TavernGroupActivation.manual, + addGreetings: false, + ); + expect((await h.engine.groupChat(chat))!.maxSpeakersPerTurn, 3); + await h.engine.updateGroupMember(chat, d, muted: true); + final noReply = await h.engine.sendGroup(chat, 'only the user').toList(); + expect(noReply.whereType().single.reason, TavernGroupEndReason.noSpeakers); + expect(_done(noReply), isEmpty); + expect((await h.engine.path(chat)).map((message) => message.text), ['only the user']); + expect(h.requests, isEmpty); + expect(h.keyReads, 0); + expect(draws, 0); + h.json('explicit muted member'); + final explicit = await h.engine.sendGroup(chat, '', speakerCharacterId: d).toList(); + expect(_done(explicit).single.speakerCharacterId, d); + expect(draws, 0); + await h.engine.updateGroupMember(chat, d, muted: false); + await h.engine.updateGroupChat(chat, activation: TavernGroupActivation.listOrder); + for (final input in ['list one', 'list two']) { + h.json('a'); + h.json('b'); + h.json('c'); + final ordered = await h.engine.sendGroup(chat, input).toList(); + expect(_done(ordered).map((message) => message.speakerCharacterId), [a, b, c]); + expect(ordered.whereType().single.progress!.plannedCharacterIds, [a, b, c]); + } + expect(draws, 0, reason: 'LIST plus the cap starts from the roster head each time, without a rotating cursor.'); + + final pool = await h.engine.createGroupChat( + [a, b, c], + activation: TavernGroupActivation.randomPool, + addGreetings: false, + ); + final pooled = []; + for (final input in ['new user', '', '', '', 'new user again']) { + h.json('pool reply'); + final one = await h.engine.sendGroup(pool, input).toList(); + pooled.add(_done(one).single.speakerCharacterId); + expect(one.whereType().single.progress!.plannedCharacterIds, hasLength(1)); + } + expect(pooled, [c, b, a, c, c]); + expect(draws, 5); + + final natural = await h.engine.createGroupChat( + [a, b, c], + activation: TavernGroupActivation.natural, + maxSpeakersPerTurn: 1, + addGreetings: false, + ); + h.json('Alpha'); + final mentioned = await h.engine.sendGroup(natural, 'Beta Alpha').toList(); + expect(_done(mentioned).single.speakerCharacterId, b); + expect(draws, 11, reason: 'All three shuffle and three threshold draws precede the project cap.'); + await h.engine.groupChat(natural); + await h.engine.watchGroupChat(natural).first; + await h.engine.resumeGroupTurn(natural).toList(); + expect(draws, 11); + h.json('no matching name'); + final lastText = await h.engine.sendGroup(natural, '').toList(); + expect(_done(lastText).single.speakerCharacterId, a); + expect(draws, 16, reason: 'The actual last Beta reply mentions Alpha; banned Beta skips its threshold draw.'); + + final fallback = await h.engine.createGroupChat( + [a], + activation: TavernGroupActivation.natural, + addGreetings: false, + ); + for (final input in ['no matching name', '']) { + h.json('no matching name'); + final chosen = await h.engine.sendGroup(fallback, input).toList(); + expect(_done(chosen).single.speakerCharacterId, a); + } + expect(draws, 21, reason: 'The fixed ST fallback may select the sole zero-talk, self-banned actor.'); + expect(h.requests, hasLength(16)); + expect(h.keyReads, 16); + }); + + test('director scheduling consumes durable proposals on a branch only after an actor is retained', () async { + final h = await _Harness.open(); + final a = await h.card('Same', greeting: 'source greeting'); + final b = await h.card('Same'); + final muted = await h.card('Muted'); + final chat = await h.engine.createGroupChat([a, b, muted], activation: TavernGroupActivation.director); + await h.engine.updateGroupMember(chat, muted, muted: true); + final source = (await h.engine.path(chat)).single; + final empty = await h.engine.sendGroup(chat, '').toList(); + expect(empty.whereType().single.reason, TavernGroupEndReason.noSpeakers); + expect(h.requests, isEmpty); + h.json( + '', + tools: [ + _directCall( + events: [ + { + 'name': 'group.speaker', + 'data': jsonEncode({'characterId': 'unknown-character'}), + }, + { + 'name': 'group.speaker', + 'data': jsonEncode({'characterId': muted}), + }, + { + 'name': 'group.speaker', + 'data': jsonEncode({'characterId': b}), + }, + { + 'name': 'group.speaker', + 'data': jsonEncode({'characterId': a}), + }, + {'name': 'not-an-engine-command', 'data': 'must remain a proposal'}, + ], + ), + ], + reason: 'tool_calls', + ); + final proposal = await h.engine.runDirector(chat); + expect(proposal.success, isTrue, reason: proposal.failure); + final original = await h.engine.readDirectorEvents(chat); + final originalIds = original.map((event) => event.id).toList(); + expect(original, hasLength(5)); + + h.response({ + 'error': {'message': 'fixture rejected'}, + }, status: 401); + final rejected = await h.engine.sendGroup(chat, '').toList(); + expect(rejected.whereType().single.failure, TavernFailure.badKey); + expect(rejected.whereType().single.attemptedCharacterId, b); + expect((await h.engine.path(chat)).map((message) => message.nodeId), [source.nodeId]); + expect(h.requests, hasLength(2)); + h.json('B on original branch'); + h.json('A on original branch'); + final accepted = await h.engine.sendGroup(chat, '').toList(); + expect(accepted.whereType().single.reason, TavernGroupEndReason.completed); + expect(_done(accepted).map((message) => message.speakerCharacterId), [b, a]); + final originalReplies = _done(accepted).map((message) => message.nodeId).toList(); + final consumed = await h.engine.sendGroup(chat, '').toList(); + expect(consumed.whereType().single.reason, TavernGroupEndReason.noSpeakers); + expect(h.requests, hasLength(4)); + expect((await h.engine.readDirectorEvents(chat)).map((event) => event.id), originalIds); + expect((await h.engine.readDirectorEvents(chat)).every((event) => event.status == 'pending'), isTrue); + + await h.reopen(); + expect( + (await h.engine.sendGroup(chat, '').toList()).whereType().single.reason, + TavernGroupEndReason.noSpeakers, + ); + await h.engine.selectNode(chat, source.nodeId); + h.json('B on another branch'); + h.json('A on another branch'); + final another = await h.engine.sendGroup(chat, '').toList(); + expect(_done(another).map((message) => message.speakerCharacterId), [b, a]); + expect(_done(another).map((message) => message.nodeId).toSet().intersection(originalReplies.toSet()), isEmpty); + expect((await h.engine.tree(chat)).map((message) => message.nodeId), containsAll(originalReplies)); + expect((await h.engine.readDirectorEvents(chat)).map((event) => event.id), originalIds); + expect(h.requests, hasLength(6)); + + await h.engine.selectNode(chat, source.nodeId); + await h.engine.edit(chat, source.nodeId, 'changed source fingerprint'); + final stale = await h.engine.sendGroup(chat, '').toList(); + expect(stale.whereType().single.reason, TavernGroupEndReason.noSpeakers); + expect((await h.engine.readDirector(chat)).stale, isTrue); + expect(h.requests, hasLength(6), reason: 'No implicit director rescue, stale reply or unrequested actor call.'); + }); + + test('manual tree mutation drains the whole group before writing and permits the next explicit actor', () async { + final h = await _Harness.open(); + await h.engine.setPreset(_preset(streaming: true)); + final a = await h.card('A'); + final b = await h.card('B'); + final c = await h.card('C'); + final chat = await h.engine.createGroupChat( + [a, b, c], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final call = h.stream(); + final events = _Events(h.engine.sendGroup(chat, 'before mutation')); + await events.waitRequest(call); + await call.listening.future; + call.delta({'content': 'retained partial'}); + await events.until((event) => _hasDelta(event, a, 'retained partial')); + final narrated = await h.engine.narrate(chat, ' narrator after cancel ', speaker: 'Narrator'); + await events.done.future; + expect(events.values.whereType().single.reason, TavernGroupEndReason.cancelled); + expect(h.requests, hasLength(1)); + expect( + (narrated.role, narrated.text, narrated.speakerCharacterId), + (TavernRole.narrator, 'narrator after cancel', null), + ); + expect((await h.engine.path(chat)).map((message) => message.text), [ + 'before mutation', + 'retained partial', + 'narrator after cancel', + ]); + await h.engine.setPreset(_preset()); + h.json('explicit B after mutation'); + final next = await h.engine.sendGroup(chat, '', speakerCharacterId: b).toList(); + expect(next.whereType().single.reason, TavernGroupEndReason.completed); + expect(_done(next).single.speakerCharacterId, b); + expect(_done(next).single.parentId, narrated.nodeId); + expect(h.requests, hasLength(2)); + expect(h.keyReads, 2); + }); + + test('main and helper actor wire routes persist through explicit regeneration and continuation', () async { + final h = await _Harness.open(deepseek: true, thinking: true); + final a = await h.card('A'); + final b = await h.card('B'); + final chat = await h.engine.createGroupChat( + [a, b], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + h.json('main A'); + h.json('helper B'); + final events = await h.engine.sendGroup(chat, 'route').toList(); + expect(events.whereType().single.reason, TavernGroupEndReason.completed); + final replies = _done(events); + expect(replies.map((message) => message.speakerCharacterId), [a, b]); + expect(h.requests.map((request) => request.body['model']), ['p2-group-model', 'deepseek-flash']); + expect(h.requests.map((request) => request.body['thinking']), [ + {'type': 'enabled'}, + {'type': 'disabled'}, + ]); + expect(h.requests.map((request) => request.url), [ + Uri.parse('https://p2-group.invalid/beta/chat/completions'), + Uri.parse('https://p2-group.invalid/beta/chat/completions'), + ]); + for (final request in h.requests) { + expect(request.body['temperature'], 0.4); + expect(request.body['max_tokens'], 73); + expect(request.body['stream'], isFalse); + expect(request.body.containsKey('tools'), isFalse); + expect(request.body.containsKey('tool_choice'), isFalse); + } + await h.engine.updateGroupMember(chat, b, muted: true); + h.json('B regenerated'); + final regenerated = await h.engine.regenerate(chat, replies[1].nodeId).toList(); + expect(regenerated.whereType(), isEmpty); + final replacement = regenerated.whereType().single.message; + expect((replacement.speakerCharacterId, replacement.parentId, replacement.siblingCount), (b, replies[0].nodeId, 2)); + expect(h.requests.last.body['model'], 'deepseek-flash'); + expect(h.requests.last.body['thinking'], {'type': 'disabled'}); + h.json(' continued'); + final continued = await h.engine.continueReply(chat, replacement.nodeId).toList(); + expect(continued.whereType(), isEmpty); + final continuedMessage = continued.whereType().single.message; + expect( + (continuedMessage.nodeId, continuedMessage.speakerCharacterId, continuedMessage.text), + (replacement.nodeId, b, 'B regenerated continued'), + ); + expect(_maps(h.requests.last.body['messages']).last['prefix'], isTrue); + expect(h.requests.last.body['model'], 'deepseek-flash'); + await h.engine.removeGroupMember(chat, b); + final removed = await h.engine.regenerate(chat, replacement.nodeId).toList(); + expect(removed.whereType(), isEmpty); + expect(removed.whereType().single.kind, TavernFailure.invalidParameters); + expect(removed.whereType().single.detail, 'group_actor_not_in_roster'); + expect(h.requests, hasLength(4)); + expect(h.keyReads, 4); + expect((await h.engine.tree(chat)).where((node) => node.nodeId == replies[1].nodeId).single.speakerCharacterId, b); + }); + + test('host state is shared per actor while story and automatic director count one complete batch', () async { + final h = await _Harness.open(); + final host = await h.card( + 'Host', + extensions: { + 'dshgo': { + 'v': 1, + 'state': _counterState(), + 'story': { + 'acts': [ + {'id': 'waiting', 'title': 'HOST_STORY', 'when': false}, + ], + }, + }, + }, + ); + final foreign = { + 'dshgo': { + 'v': 1, + 'state': _counterState(initial: 99, summary: 'ACTOR_SCHEMA_MUST_NOT_RUN'), + 'story': { + 'acts': [ + {'id': 'wrong', 'when': true}, + ], + }, + }, + }; + final a = await h.card('A', extensions: foreign); + final b = await h.card('B', extensions: foreign); + final chat = await h.engine.createGroupChat( + [a, b], + hostCharacterId: host, + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + await h.engine.setSettings({ + 'director': {'enabled': true, 'every': 1}, + }, chatId: chat); + expect((await h.engine.readState(chat)).values, {'count': 0}); + h.json('A changes', tools: [_stateCall(10)], reason: 'tool_calls'); + final second = h.json('B changes', tools: [_stateCall(20)], reason: 'tool_calls'); + Object? beforeB; + _Json? storyBeforeB; + second.beforeResponse = () async { + beforeB = (await h.engine.readState(chat)).values; + storyBeforeB = (await h.engine.readDirector(chat)).story; + }; + h.json('not chat text', tools: [_directCall()], reason: 'tool_calls'); + final events = await h.engine.sendGroup(chat, 'state round').toList(); + expect(events.whereType().single.reason, TavernGroupEndReason.completed); + final director = await h.engine + .watchDirector(chat) + .firstWhere((snapshot) => snapshot.pendingJobs == 0 && snapshot.latestAttempt != null); + expect(director.latestAttempt!.success, isTrue, reason: director.latestAttempt!.failure); + expect(beforeB, {'count': 1}); + expect(storyBeforeB!['initialized'], isFalse); + expect((await h.engine.readState(chat)).values, {'count': 2}); + expect(director.story!['currentActId'], 'waiting'); + expect(director.story!['actTurns'], 1); + expect(director.latestSuccess!.automatic, isTrue); + expect(director.latestSuccess!.sourceNodeId, _done(events).last.nodeId); + expect(h.requests, hasLength(3)); + expect(h.keyReads, 3); + expect(_text(h.requests[0].body), contains('HOST_COUNT=0')); + expect(_text(h.requests[1].body), contains('HOST_COUNT=1')); + expect(_text(h.requests[1].body), contains('A: A changes')); + for (final request in h.requests.take(2)) { + expect(_text(request.body), isNot(contains('ACTOR_SCHEMA_MUST_NOT_RUN'))); + expect(_maps(request.body['tools']).map((tool) => _map(tool['function'])['name']), ['update_state']); + } + final helper = h.requests[2].body; + expect(_maps(helper['tools']).map((tool) => _map(tool['function'])['name']), ['direct']); + expect(helper['stream'], isFalse); + expect(helper['max_tokens'], 4096); + final input = _map(jsonDecode(_maps(helper['messages']).last['content']! as String)); + expect(_map(input['card'])['name'], 'Host'); + final runs = await h.engine.readAgentRuns(chat, limit: 1000); + expect(runs.where((run) => run.kind == 'story.rules'), hasLength(1)); + expect(runs.where((run) => run.kind == 'director.tick'), hasLength(1)); + final path = await h.engine.path(chat); + expect(path.map((message) => (message.role, message.text, message.speakerCharacterId)), [ + (TavernRole.user, 'state round', null), + (TavernRole.character, 'A changes', a), + (TavernRole.character, 'B changes', b), + ]); + final savedDirector = director.latestSuccess!.id; + await h.reopen(); + expect((await h.engine.readState(chat)).values, {'count': 2}); + final after = await h.engine.readDirector(chat); + expect(after.story!['actTurns'], 1); + expect(after.latestSuccess!.id, savedDirector); + expect(h.requests, hasLength(3)); + + // A successful child of a later failed batch keeps its legal state edit, + // but must not advance the story or schedule another automatic helper. + h.json('A retained', tools: [_stateCall(30)], reason: 'tool_calls'); + h.response({ + 'error': {'message': 'fixture rejected'}, + }, status: 401); + final partial = await h.engine.sendGroup(chat, 'failed batch').toList(); + expect(partial.whereType().single.reason, TavernGroupEndReason.failed); + expect((await h.engine.readState(chat)).values, {'count': 3}); + final unchanged = await h.engine.readDirector(chat); + expect(unchanged.story!['actTurns'], 1); + expect(unchanged.latestSuccess!.id, savedDirector); + expect(unchanged.pendingJobs, 0); + final laterRuns = await h.engine.readAgentRuns(chat, limit: 1000); + expect(laterRuns.where((run) => run.kind == 'story.rules'), hasLength(1)); + expect(laterRuns.where((run) => run.kind == 'director.tick'), hasLength(1)); + expect(h.requests, hasLength(5)); + }); + + test('a zero-output actor failure keeps the user and prior actor without retrying or starting another', () async { + final h = await _Harness.open(); + final a = await h.card('A'); + final b = await h.card('B'); + final c = await h.card('C'); + final chat = await h.engine.createGroupChat( + [a, b, c], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + h.json('A durable'); + h.response({ + 'error': {'message': 'bad fixture key'}, + }, status: 401); + final events = await h.engine.sendGroup(chat, 'retain user').toList(); + final finish = events.whereType().single; + expect( + (finish.reason, finish.failure, finish.attemptedCharacterId), + (TavernGroupEndReason.failed, TavernFailure.badKey, b), + ); + expect(finish.partial, isNull); + expect(finish.progress!.remainingCharacterIds, [c]); + expect(finish.progress!.completedNodeIds, [_done(events).single.nodeId]); + expect(h.requests, hasLength(2)); + expect(h.keyReads, 2); + final path = await h.engine.path(chat); + expect(path.map((row) => (row.role, row.text, row.speakerCharacterId)), [ + (TavernRole.user, 'retain user', null), + (TavernRole.character, 'A durable', a), + ]); + final tree = await h.engine.tree(chat); + expect(tree.map((node) => node.nodeId), unorderedEquals(path.map((node) => node.nodeId))); + await h.reopen(); + expect((await h.engine.path(chat)).map((row) => row.nodeId), path.map((row) => row.nodeId)); + expect(h.requests, hasLength(2)); + + final empty = await h.engine.createGroupChat( + [a, b], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + h.response({ + 'error': {'message': 'bad fixture key'}, + }, status: 401); + final firstFailure = await h.engine.sendGroup(empty, 'first user retained').toList(); + final failed = firstFailure.whereType().single; + expect(failed.failure, TavernFailure.badKey); + expect(failed.partial, isNull); + expect(failed.progress!.completedNodeIds, isEmpty); + expect(failed.progress!.remainingCharacterIds, [b]); + expect((await h.engine.path(empty)).map((row) => (row.role, row.text)), [(TavernRole.user, 'first user retained')]); + expect(h.requests, hasLength(3)); + }); +} + +bool _hasDelta(TavernGroupEvent event, String actor, String text) => + event is TavernGroupMemberEvent && + event.characterId == actor && + event.event is TavernTextDelta && + (event.event as TavernTextDelta).text == text; + +String _text(_Json body) => _maps(body['messages']).map((message) => message['content']).whereType().join('\n'); + +_Json _counterState({int initial = 0, String summary = 'HOST_COUNT={{state:/count}}'}) => { + 'schema': { + 'type': 'object', + 'additionalProperties': false, + 'required': ['count'], + 'properties': { + 'count': {'type': 'integer', 'minimum': 0, 'maximum': 100, 'default': 0, 'x-dsh-step': 1}, + }, + }, + 'init': {'count': initial}, + 'summary': summary, + 'depth': 1, +}; + +_Json _stateCall(int value) => { + 'id': 'state-$value', + 'type': 'function', + 'function': { + 'name': 'update_state', + 'arguments': jsonEncode({ + 'ops': [ + {'op': 'set', 'path': '/count', 'value': value, 'why': 'per actor allowance'}, + ], + }), + }, +}; + +_Json _directCall({List<_Json> events = const []}) => { + 'id': 'group-direct', + 'type': 'function', + 'function': { + 'name': 'direct', + 'arguments': jsonEncode({ + 'notes': 'PRIVATE_DIRECTOR_NOT_CHAT', + 'beat': 'NEXT_BEAT', + 'prefetch': [], + 'suggestions': [ + {'text': 'one', 'risk': ''}, + {'text': 'two', 'risk': ''}, + {'text': 'three', 'risk': ''}, + ], + 'events': events, + }), + }, +}; + +class _Events { + _Events(Stream source) { + subscription = source.listen( + (event) { + values.add(event); + changes.add(event); + }, + onError: (Object error, StackTrace stack) { + if (!done.isCompleted) done.completeError(error, stack); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + unawaited(changes.close()); + }, + ); + addTearDown(detach); + } + final values = []; + final changes = StreamController.broadcast(sync: true); + final done = Completer(); + late final StreamSubscription subscription; + Future until(bool Function(TavernGroupEvent) accept) { + for (final event in values) { + if (accept(event)) return Future.value(event); + } + return Future.any([ + changes.stream.firstWhere(accept), + done.future.then((_) => throw TestFailure('Group ended before expected event: $values')), + ]); + } + + Future<_Request> waitRequest(_Plan plan) => Future.any([ + plan.seen.future, + done.future.then<_Request>((_) => throw TestFailure('Group ended before expected HTTP request: $values')), + ]); + Future detach() async { + await subscription.cancel(); + if (!changes.isClosed) await changes.close(); + } +} + +List _done(Iterable events) => [ + for (final event in events.whereType()) + if (event.event case TavernTurnDone(:final message)) message, +]; + +_Json _map(Object? value) => value! as _Json; +List<_Json> _maps(Object? value) => (value! as List).cast<_Json>(); + +ChatPreset _preset({bool streaming = false, bool controls = false}) { + final raw = t24Preset().raw; + raw.addAll({ + 'stream_openai': streaming, + 'openai_max_context': 8192, + 'openai_max_tokens': 73, + 'temperature': 0.4, + 'presence_penalty': 0.2, + 'frequency_penalty': 0.1, + 'top_p': 0.9, + 'seed': -1, + 'squash_system_messages': false, + 'new_group_chat_prompt': controls ? 'NEW {{group}}' : '', + 'group_nudge_prompt': controls ? 'NEXT {{char}};ALL {{group}};ACTIVE {{groupNotMuted}}' : '', + }); + _maps(raw['prompts']).firstWhere((prompt) => prompt['identifier'] == 'main')['content'] = controls + ? 'MAIN {{char}}' + : ''; + return ChatPreset(raw); +} + +_Json _body(List<_Json> messages) => { + 'messages': messages, + 'model': 'p2-group-model', + 'temperature': 0.4, + 'max_tokens': 73, + 'stream': false, + 'presence_penalty': 0.2, + 'frequency_penalty': 0.1, + 'top_p': 0.9, +}; + +_Json _completion(String text, {String reason = 'stop', List<_Json> tools = const []}) => { + 'choices': [ + { + 'index': 0, + 'message': {'role': 'assistant', 'content': text, if (tools.isNotEmpty) 'tool_calls': tools}, + 'finish_reason': reason, + }, + ], +}; + +class _Harness { + _Harness(this.root, {required this.random}); + static Future<_Harness> open({bool deepseek = false, bool thinking = false, double Function()? random}) async { + final root = await Directory.systemTemp.createTemp('dsh-p2-group-turn-'); + final h = _Harness(root, random: random ?? () => 0.25); + addTearDown(h.dispose); + h.config = TavernModelConfig( + apiKey: () async { + h.keyReads++; + return 'p2-group-fixture-key'; + }, + baseUrl: 'https://p2-group.invalid/v1', + model: 'p2-group-model', + deepseek: deepseek, + thinking: thinking, + ); + h.model = TavernModelClient( + clientFactory: () { + final peer = _Peer(h); + h.peers.add(peer); + return peer; + }, + ); + h.engine = await h._openEngine(); + h.isOpen = true; + await h.engine.setPreset(_preset()); + await h.engine.setSettings({ + 'userName': 'User', + 'macroEngine': 'modern', + 'globalRegexScripts': [], + 'worldInfo': { + 'world_info_depth': 10, + 'world_info_budget': 80, + 'world_info_include_names': false, + 'world_info_recursive': false, + }, + }); + return h; + } + + final Directory root; + final double Function() random; + late TavernModelConfig config; + late TavernModelClient model; + late TavernEngine engine; + final peers = <_Peer>[]; + final requests = <_Request>[]; + final pending = <_Plan>[]; + final allPlans = <_Plan>[]; + var keyReads = 0; + var isOpen = false; + + Future _openEngine() => TavernEngine.openDirectory( + root, + config: config, + modelClient: model, + clock: () => DateTime.utc(2026, 11, 12, 4), + random: random, + ); + Future reopen() async { + await engine.close(); + isOpen = false; + engine = await _openEngine(); + isOpen = true; + } + + _Plan json(String text, {String reason = 'stop', List<_Json> tools = const []}) => + response(_completion(text, reason: reason, tools: tools)); + _Plan response(_Json body, {int status = 200}) { + final plan = _Plan.json(body, status: status); + pending.add(plan); + allPlans.add(plan); + return plan; + } + + _Plan stream() { + final plan = _Plan.stream(); + pending.add(plan); + allPlans.add(plan); + return plan; + } + + Future card( + String name, { + String description = '', + String greeting = '', + String? book, + int order = 100, + String? replacement, + _Json extensions = const {}, + Object? talkativeness, + }) async { + final raw = { + 'spec': 'chara_card_v3', + 'spec_version': '3.0', + 'data': { + 'name': name, + 'description': description, + 'personality': '', + 'scenario': '', + 'first_mes': greeting, + 'mes_example': '', + 'creator_notes': '', + 'system_prompt': '', + 'post_history_instructions': '', + 'alternate_greetings': [], + 'tags': [], + 'creator': '', + 'character_version': '', + 'talkativeness': ?talkativeness, + 'extensions': { + ...extensions, + if (replacement != null) + 'regex_scripts': [ + { + 'id': 'write-$name', + 'scriptName': 'actor $name', + 'findRegex': '/RAW/g', + 'replaceString': replacement, + 'placement': [2], + 'trimStrings': [], + 'disabled': false, + 'markdownOnly': false, + 'promptOnly': false, + 'runOnEdit': false, + 'substituteRegex': 0, + }, + ], + }, + if (book != null) + 'character_book': { + 'name': 'same book label', + 'extensions': {}, + 'entries': [ + { + 'id': 0, + 'keys': [], + 'secondary_keys': [], + 'content': book, + 'enabled': true, + 'constant': true, + 'insertion_order': order, + 'position': 'before_char', + 'extensions': {'useProbability': false}, + }, + ], + }, + }, + }; + return (await engine.importCard(Uint8List.fromList(utf8.encode(jsonEncode(raw))))).id; + } + + Future dispose() async { + try { + if (isOpen) await engine.close(); + } finally { + for (final plan in allPlans) { + await plan.dispose(); + } + // Engine cancellation releases its tree without promising async* HTTP + // drainage. The fixture owns these synthetic peers and waits for their + // actual finally/close after it has released every response stream. + await Future.wait([for (final peer in peers) peer.closedSignal.future]); + if (await root.exists()) await root.delete(recursive: true); + } + expect(await root.exists(), isFalse); + expect(peers.every((peer) => peer.closed), isTrue); + stdout.writeln('P2_GROUP_TURN_CLEANUP'); + } +} + +class _Plan { + _Plan.json(this.jsonBody, {this.status = 200}) : source = null; + _Plan.stream() : jsonBody = null, status = 200 { + source = StreamController>( + sync: true, + onListen: () { + listening.complete(); + }, + ); + } + final _Json? jsonBody; + final int status; + StreamController>? source; + final seen = Completer<_Request>(); + final listening = Completer(); + Future Function()? beforeResponse; + var sends = 0; + void delta(_Json delta) => source!.add( + utf8.encode( + 'data: ${jsonEncode({ + 'choices': [ + {'index': 0, 'delta': delta, 'finish_reason': null}, + ], + })}\n\n', + ), + ); + void finish({String reason = 'stop'}) { + source!.add( + utf8.encode( + 'data: ${jsonEncode({ + 'choices': [ + {'index': 0, 'delta': {}, 'finish_reason': reason}, + ], + })}\n\ndata: [DONE]\n\n', + ), + ); + unawaited(source!.close()); + } + + Future dispose() async { + final stream = source; + if (stream == null || stream.isClosed) return; + if (listening.isCompleted) { + await stream.close(); + } else { + unawaited(stream.close()); + } + } +} + +class _Peer extends http.BaseClient { + _Peer(this.h); + final _Harness h; + final closedSignal = Completer(); + var closed = false; + @override + Future send(http.BaseRequest request) async { + final body = _map(jsonDecode(utf8.decode(await request.finalize().toBytes()))); + final captured = (method: request.method, url: request.url, body: body); + h.requests.add(captured); + if (h.pending.isEmpty) throw StateError('Unexpected additional group HTTP call.'); + final plan = h.pending.removeAt(0); + plan.sends++; + plan.seen.complete(captured); + await plan.beforeResponse?.call(); + return http.StreamedResponse( + plan.source?.stream ?? Stream.value(utf8.encode(jsonEncode(plan.jsonBody))), + plan.status, + headers: {'content-type': plan.source == null ? 'application/json' : 'text/event-stream'}, + ); + } + + @override + void close() { + closed = true; + if (!closedSignal.isCompleted) closedSignal.complete(); + } +} diff --git a/packages/dsh_tavern/test/t12a_director_store_test.dart b/packages/dsh_tavern/test/t12a_director_store_test.dart index 55c53322d9..c307bf8d1c 100644 --- a/packages/dsh_tavern/test/t12a_director_store_test.dart +++ b/packages/dsh_tavern/test/t12a_director_store_test.dart @@ -7,7 +7,7 @@ import 'package:test/test.dart'; // Real SQLite queries and a fixed historical schema. No elapsed-time claim or // production query hook substitutes for the result and migration contracts. void main() { - test('a new V6 database creates the exact chat and kind index and reopens without replaying it', () async { + test('a new V7 database retains the exact chat and kind index and reopens without replaying it', () async { final directory = await _temporaryDirectory(); final path = '${directory.path}/current.sqlite3'; final first = ChatStore.open(path); @@ -18,8 +18,9 @@ void main() { } final database = sqlite3.open(path); final store = _own(database); - expect(ChatStore.schemaVersion, 6); - expect(_version(database), 6); + expect(ChatStore.schemaVersion, 7); + expect(_version(database), 7); + _expectEmptyGroups(database); expect(_indexColumns(database), ['chat_id', 'kind']); expect(database.select("SELECT name FROM sqlite_master WHERE name = 'agent_runs_chat_kind'"), hasLength(1)); expect(store.chat('kept').characterId, 'card'); @@ -175,25 +176,29 @@ void main() { expect(_data(database), before); }); - test('a genuine V5 database gains only the index while user rows and original JSON bytes survive', () async { - final directory = await _temporaryDirectory(); - final database = _createV5('${directory.path}/old.sqlite3'); - expect(_version(database), 5); - expect(_indexColumns(database), isEmpty); - final before = _data(database, includeVendor: true); - final store = _own(database); - expect(_version(database), 6); - expect(_indexColumns(database), ['chat_id', 'kind']); - expect(_data(database, includeVendor: true), before); - expect(_ids(store.agentRunsByKind('chat', {'director.success', 'director.failure'})), ['audit-new', 'audit-old']); - final history = store.historyTo('chat', 'leaf'); - expect(history.map((node) => node.message.nodeId), ['root', 'leaf']); - expect(history.last.message.usage!.cacheHitTokens, 1); - expect(history.last.message.reportedUsage!.cacheHitTokens, 2); - expect(database.select("SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH 'V5needle'"), hasLength(1)); - expect(database.select('PRAGMA integrity_check').single['integrity_check'], 'ok'); - expect(database.select('PRAGMA foreign_key_check'), isEmpty); - }); + test( + 'a genuine V5 database gains the index and group schema while user rows and original JSON bytes survive', + () async { + final directory = await _temporaryDirectory(); + final database = _createV5('${directory.path}/old.sqlite3'); + expect(_version(database), 5); + expect(_indexColumns(database), isEmpty); + final before = _data(database, includeVendor: true); + final store = _own(database); + expect(_version(database), 7); + _expectEmptyGroups(database); + expect(_indexColumns(database), ['chat_id', 'kind']); + expect(_data(database, includeVendor: true), before); + expect(_ids(store.agentRunsByKind('chat', {'director.success', 'director.failure'})), ['audit-new', 'audit-old']); + final history = store.historyTo('chat', 'leaf'); + expect(history.map((node) => node.message.nodeId), ['root', 'leaf']); + expect(history.last.message.usage!.cacheHitTokens, 1); + expect(history.last.message.reportedUsage!.cacheHitTokens, 2); + expect(database.select("SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH 'V5needle'"), hasLength(1)); + expect(database.select('PRAGMA integrity_check').single['integrity_check'], 'ok'); + expect(database.select('PRAGMA foreign_key_check'), isEmpty); + }, + ); test('a real V6 DDL failure rolls back the version and preserves V5 rows for a clean retry', () async { final directory = await _temporaryDirectory(); @@ -215,7 +220,8 @@ void main() { } final database = sqlite3.open(path); _own(database); - expect(_version(database), 6); + expect(_version(database), 7); + _expectEmptyGroups(database); expect(_indexColumns(database), ['chat_id', 'kind']); expect(_data(database, includeVendor: true), before); }); @@ -239,7 +245,7 @@ void main() { await ChatStore.validateBackup(staging, sourceSchemaVersion: 5); final migrated = sqlite3.open(staging.path, mode: OpenMode.readOnly); try { - expect(_version(migrated), 6); + expect(_version(migrated), 7); expect(_indexColumns(migrated), ['chat_id', 'kind']); expect(_data(migrated, includeVendor: true), before); } finally { @@ -272,7 +278,7 @@ void main() { final renamed = await staging.rename('${directory.path}/validated.sqlite3'); final check = sqlite3.open(renamed.path, mode: OpenMode.readOnly); try { - expect(_version(check), 6); + expect(_version(check), 7); expect(_data(check, includeVendor: true), before); } finally { check.close(); @@ -280,14 +286,14 @@ void main() { expect(await archive.readAsBytes(), original); }); - test('an online V6 backup includes the index and latest audit rows while its source remains writable', () async { + test('an online V7 backup includes the index and latest audit rows while its source remains writable', () async { final directory = await _temporaryDirectory(); final database = sqlite3.open('${directory.path}/live.sqlite3'); final store = _own(database); _chats(store); _run(store, 'before-backup', 'director.success'); final destination = File('${directory.path}/snapshot.sqlite3'); - expect(await store.backupToFile(destination), 6); + expect(await store.backupToFile(destination), 7); final captured = sqlite3.open(destination.path, mode: OpenMode.readOnly); try { expect(captured.select('PRAGMA journal_mode').single['journal_mode'], 'delete'); @@ -295,10 +301,10 @@ void main() { captured.close(); } _run(store, 'after-backup', 'director.failure'); - await ChatStore.validateBackup(destination, sourceSchemaVersion: 6); + await ChatStore.validateBackup(destination, sourceSchemaVersion: 7); final copy = sqlite3.open(destination.path, mode: OpenMode.readOnly); try { - expect(_version(copy), 6); + expect(_version(copy), 7); expect(_indexColumns(copy), ['chat_id', 'kind']); expect(copy.select('SELECT id FROM agent_runs ORDER BY rowid').map((row) => row['id']), ['before-backup']); } finally { @@ -386,10 +392,26 @@ Map _data(Database database, {bool includeVendor = false}) => { ]) table: [ for (final row in database.select('SELECT rowid AS storage_rowid, * FROM $table ORDER BY rowid')) - Map.from(row), + _legacyRow(table, row), ], }; +// Compare every historical column byte-for-byte while checking the one new +// nullable identity field explicitly. Unknown vendor columns are never removed. +Map _legacyRow(String table, Row row) { + final result = Map.from(row); + if (table == 'nodes' && result.containsKey('speaker_character_id')) { + expect(result['speaker_character_id'], isNull); + result.remove('speaker_character_id'); + } + return result; +} + +void _expectEmptyGroups(Database database) { + expect(database.select('SELECT * FROM group_chats'), isEmpty); + expect(database.select('SELECT * FROM group_members'), isEmpty); +} + Database _createV5(String path) { final database = sqlite3.open(path); try { diff --git a/packages/dsh_tavern/test/t17_agent_runs_store_test.dart b/packages/dsh_tavern/test/t17_agent_runs_store_test.dart index ccf80ce725..4142bbd8a6 100644 --- a/packages/dsh_tavern/test/t17_agent_runs_store_test.dart +++ b/packages/dsh_tavern/test/t17_agent_runs_store_test.dart @@ -296,7 +296,7 @@ void main() { expect(runs.singleWhere((run) => run.id == 'visible').nodeId, 'root'); expect(runs.singleWhere((run) => run.id == 'purged').nodeId, isNull); expect(h.database.select('PRAGMA user_version').single['user_version'], ChatStore.schemaVersion); - expect(ChatStore.schemaVersion, 6); + expect(ChatStore.schemaVersion, 7); expect(h.database.select('PRAGMA foreign_key_check'), isEmpty); }); }); diff --git a/packages/dsh_tavern/test/t18_director_store_test.dart b/packages/dsh_tavern/test/t18_director_store_test.dart index 3e0545507d..12f5172699 100644 --- a/packages/dsh_tavern/test/t18_director_store_test.dart +++ b/packages/dsh_tavern/test/t18_director_store_test.dart @@ -156,7 +156,7 @@ void main() { expect(empty.result['sourceKind'], 'empty'); expect(empty.result['sourceNodeId'], isNull); expect(h.database.select('PRAGMA foreign_key_check'), isEmpty); - expect(h.database.select('PRAGMA user_version').single['user_version'], 6); + expect(h.database.select('PRAGMA user_version').single['user_version'], 7); }); test('summaries require both endpoints in source order and exclude other branches and chats', () async { @@ -337,7 +337,7 @@ void main() { expect(h.store.directorMemories(chat).last.valid, isFalse); expect(h.store.directorMemories(chat).last.entry, isEmpty); expect(h.rows(), before); - expect(h.database.select('PRAGMA user_version').single['user_version'], 6); + expect(h.database.select('PRAGMA user_version').single['user_version'], 7); }, ); } diff --git a/packages/dsh_tavern/test/t20_schema_migration_test.dart b/packages/dsh_tavern/test/t20_schema_migration_test.dart index 3fba7f9a2c..f94752f98c 100644 --- a/packages/dsh_tavern/test/t20_schema_migration_test.dart +++ b/packages/dsh_tavern/test/t20_schema_migration_test.dart @@ -44,6 +44,8 @@ void main() { 'nodes_fts', 'presets', 'preset_preferences', + 'group_chats', + 'group_members', ]), ); expect(database.select('SELECT * FROM preset_preferences').single['default_preset_id'], isNull); @@ -112,7 +114,8 @@ void main() { 'chats': ', tree_revision INTEGER NOT NULL DEFAULT 0', 'nodes': ', stream_text TEXT, stream_reasoning TEXT, ' - 'deleted_by TEXT REFERENCES deleted_subtrees(id) DEFERRABLE INITIALLY DEFERRED, reported_usage TEXT', + 'deleted_by TEXT REFERENCES deleted_subtrees(id) DEFERRABLE INITIALLY DEFERRED, reported_usage TEXT, ' + 'speaker_character_id TEXT', }; for (final row in retainedDefinitions) { final addition = additions[row['name']]; @@ -123,6 +126,9 @@ void main() { } } expect(retainedDefinitions, oldDefinitions); + expect(database.select('SELECT * FROM group_chats'), isEmpty); + expect(database.select('SELECT * FROM group_members'), isEmpty); + expect(opened.path('chat').map((message) => message.speakerCharacterId), [null, null]); expect(database.select('PRAGMA application_id').single['application_id'], 765); expect(database.select('PRAGMA foreign_key_check'), isEmpty); expect( @@ -271,14 +277,15 @@ Map _legacyData(Database database) => { }; void _expectUpgradedLegacyData(Database database, Object? expected) { // Keep every old row, field, and unknown column under exact comparison. - // Strip only the explicitly checked T23/T24 additions from this genuine v1 + // Strip only the explicitly checked T23/T24/P2 additions from this genuine v1 // fixture. The old payload has no owned inspection to backfill. - const addedNames = {'stream_text', 'stream_reasoning', 'deleted_by', 'reported_usage'}; + const addedNames = {'stream_text', 'stream_reasoning', 'deleted_by', 'reported_usage', 'speaker_character_id'}; expect(_rows(database, 'PRAGMA table_info(nodes)').where((row) => addedNames.contains(row['name'])).toList(), [ {'cid': 15, 'name': 'stream_text', 'type': 'TEXT', 'notnull': 0, 'dflt_value': null, 'pk': 0}, {'cid': 16, 'name': 'stream_reasoning', 'type': 'TEXT', 'notnull': 0, 'dflt_value': null, 'pk': 0}, {'cid': 17, 'name': 'deleted_by', 'type': 'TEXT', 'notnull': 0, 'dflt_value': null, 'pk': 0}, {'cid': 18, 'name': 'reported_usage', 'type': 'TEXT', 'notnull': 0, 'dflt_value': null, 'pk': 0}, + {'cid': 19, 'name': 'speaker_character_id', 'type': 'TEXT', 'notnull': 0, 'dflt_value': null, 'pk': 0}, ]); expect(_rows(database, 'PRAGMA table_info(chats)').where((row) => row['name'] == 'tree_revision').toList(), [ {'cid': 13, 'name': 'tree_revision', 'type': 'INTEGER', 'notnull': 1, 'dflt_value': '0', 'pk': 0}, From c6301ae493274527b9049ce9d658960aaf0a3466 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 15:28:46 +0800 Subject: [PATCH 02/51] =?UTF-8?q?feat(tavern):=20App=20=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E7=BE=A4=E8=81=8A=E6=8E=A5=E5=8F=A3=EF=BC=88=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E5=92=8C=E9=A2=84=E8=A7=88=E6=A8=A1=E6=8B=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeferredTavern 转发十个群方法;createGroupChat / reorderGroupMembers 的名单在等 引擎之前先拷一份。 - PreviewTavern 在内存里模拟群聊:名单和设置、列表/自然/随机/手动排程,外层事件 Started → ready → speaking(MemberEvent 包住每人自己的回合)→ settling → Finished; stop 取消整批,正在说的保留已写的部分、后面的人不开始;resumeGroupTurn 当下快照 加之后的事件,一起接上不漏;聊天摘要标出群和成员。 - 5 项测试。界面(建群、成员设置、按发言人显示)下一步做。 --- app/lib/tavern/deferred_tavern.dart | 95 +++++ app/lib/tavern/tavern_preview.dart | 397 +++++++++++++++++- .../tavern/tavern_group_preview_test.dart | 153 +++++++ 3 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 app/test/tavern/tavern_group_preview_test.dart diff --git a/app/lib/tavern/deferred_tavern.dart b/app/lib/tavern/deferred_tavern.dart index 0b10c49da0..66e9ef6533 100644 --- a/app/lib/tavern/deferred_tavern.dart +++ b/app/lib/tavern/deferred_tavern.dart @@ -129,6 +129,101 @@ class DeferredTavern implements Tavern { @override Stream send(String chatId, String text) => _stream((t) => t.send(chatId, text)); + // Group chats (engine P2 125): the lists are copied as they are when called, + // before waiting for the engine. + + @override + Future createGroupChat( + List characterIds, { + String? hostCharacterId, + String? title, + TavernGroupActivation activation = TavernGroupActivation.natural, + int maxSpeakersPerTurn = 3, + bool allowSelfResponses = false, + bool addGreetings = true, + }) { + final ids = List.of(characterIds); + return _ready.then( + (t) => t.createGroupChat( + ids, + hostCharacterId: hostCharacterId, + title: title, + activation: activation, + maxSpeakersPerTurn: maxSpeakersPerTurn, + allowSelfResponses: allowSelfResponses, + addGreetings: addGreetings, + ), + ); + } + + @override + Future groupChat(String chatId) async => (await _ready).groupChat(chatId); + + @override + Stream watchGroupChat(String chatId) => _stream((t) => t.watchGroupChat(chatId)); + + @override + Future addGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool muted = false, + TavernGroupModelRole? modelRole, + }) async => (await _ready).addGroupMember( + chatId, + characterId, + talkativeness: talkativeness, + muted: muted, + modelRole: modelRole, + ); + + @override + Future updateGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool? muted, + TavernGroupModelRole? modelRole, + }) async => (await _ready).updateGroupMember( + chatId, + characterId, + talkativeness: talkativeness, + muted: muted, + modelRole: modelRole, + ); + + @override + Future removeGroupMember(String chatId, String characterId) async => + (await _ready).removeGroupMember(chatId, characterId); + + @override + Future reorderGroupMembers(String chatId, List characterIds) { + final ids = List.of(characterIds); + return _ready.then((t) => t.reorderGroupMembers(chatId, ids)); + } + + @override + Future updateGroupChat( + String chatId, { + String? title, + TavernGroupActivation? activation, + int? maxSpeakersPerTurn, + bool? allowSelfResponses, + }) async => (await _ready).updateGroupChat( + chatId, + title: title, + activation: activation, + maxSpeakersPerTurn: maxSpeakersPerTurn, + allowSelfResponses: allowSelfResponses, + ); + + @override + Stream sendGroup(String chatId, String text, {String? speakerCharacterId}) => + _stream((t) => t.sendGroup(chatId, text, speakerCharacterId: speakerCharacterId)); + + @override + Stream resumeGroupTurn(String chatId) => _stream((t) => t.resumeGroupTurn(chatId)); + @override Stream regenerate(String chatId, String nodeId) => _stream((t) => t.regenerate(chatId, nodeId)); diff --git a/app/lib/tavern/tavern_preview.dart b/app/lib/tavern/tavern_preview.dart index 664c224828..4d2753149e 100644 --- a/app/lib/tavern/tavern_preview.dart +++ b/app/lib/tavern/tavern_preview.dart @@ -248,23 +248,383 @@ class PreviewTavern implements Tavern, TavernPreview { Stream> watchChats({String? characterId}) => _watch(() { final list = [ for (final chat in _chats.values) - if (characterId == null || chat.characterId == characterId) _summary(chat), + if (characterId == null || + chat.characterId == characterId || + (_groups[chat.id]?.members.any((m) => m.characterId == characterId) ?? false)) + _summary(chat), ]..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); return list; }); TavernChatSummary _summary(_Chat chat) { final path = chat.path(); + final group = _groups[chat.id]; return TavernChatSummary( id: chat.id, characterId: chat.characterId, - title: chat.title, + title: group?.title ?? chat.title, updatedAt: chat.updatedAt, lastText: path.isEmpty ? null : path.last.text, messageCount: path.length, + kind: group == null ? TavernChatKind.single : TavernChatKind.group, + memberCharacterIds: [for (final m in group?.members ?? const []) m.characterId], ); } + // Group chats (engine P2 125), in memory: the roster and its settings, and + // a batch that has each planned member reply in turn on the shared path. + // The plan is simpler than the engine's: 列表 and 自然 both take the + // unmuted members in order (自然 puts one named in your line first), 随机 + // the next who has not spoken since your line, 手动 no one unless named. + + final _groups = {}; + + _Group _group(String chatId) => + _groups[chatId] ?? (throw TavernGroupException(TavernGroupError.notGroup, chatId: chatId)); + + TavernGroupChat _changed(_Group group) { + _listChanged.add(null); + return group.view(); + } + + @override + Future createGroupChat( + List characterIds, { + String? hostCharacterId, + String? title, + TavernGroupActivation activation = TavernGroupActivation.natural, + int maxSpeakersPerTurn = 3, + bool allowSelfResponses = false, + bool addGreetings = true, + }) async { + final ids = List.of(characterIds); + if (ids.toSet().length != ids.length) throw TavernGroupException(TavernGroupError.invalidMembers); + for (final id in [...ids, ?hostCharacterId]) { + if (!_characters.containsKey(id)) throw TavernGroupException(TavernGroupError.characterNotFound, characterId: id); + } + final host = + hostCharacterId ?? (ids.isEmpty ? throw TavernGroupException(TavernGroupError.invalidMembers) : ids.first); + if (maxSpeakersPerTurn < 1) throw TavernGroupException(TavernGroupError.invalidLimit); + final now = _clock(); + final name = title ?? [for (final id in ids) _characters[id]!.name].join('、'); + final chat = _Chat(_id('chat'), host, name, now); + _chats[chat.id] = chat; + _groups[chat.id] = _Group( + chat.id, + name, + host, + [ + for (final id in ids) + TavernGroupMember(characterId: id, talkativeness: 0.5, muted: false, modelRole: TavernGroupModelRole.main), + ], + activation, + maxSpeakersPerTurn, + allowSelfResponses, + ); + if (addGreetings) { + String? parent; + for (final id in ids) { + final c = _characters[id]!; + if (c.greetings.isEmpty) continue; + final node = _Node(_id('n'), parent, TavernRole.character, c.name, c.greetings.first, now)..speakerId = id; + chat.add(node); + chat.chosen[parent] = node.id; + parent = node.id; + } + } + _listChanged.add(null); + return chat.id; + } + + @override + Future groupChat(String chatId) async => _groups[chatId]?.view(); + + @override + Stream watchGroupChat(String chatId) => _watch(() => _groups[chatId]?.view()); + + @override + Future addGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool muted = false, + TavernGroupModelRole? modelRole, + }) async { + final group = _group(chatId); + if (!_characters.containsKey(characterId)) { + throw TavernGroupException(TavernGroupError.characterNotFound, chatId: chatId, characterId: characterId); + } + if (group.members.any((m) => m.characterId == characterId)) { + throw TavernGroupException(TavernGroupError.memberExists, chatId: chatId, characterId: characterId); + } + if (talkativeness != null && !(talkativeness >= 0 && talkativeness <= 1)) { + throw TavernGroupException(TavernGroupError.invalidTalkativeness, chatId: chatId, characterId: characterId); + } + group.members = [ + ...group.members, + TavernGroupMember( + characterId: characterId, + talkativeness: talkativeness ?? 0.5, + muted: muted, + modelRole: modelRole ?? TavernGroupModelRole.main, + ), + ]; + return _changed(group); + } + + @override + Future updateGroupMember( + String chatId, + String characterId, { + double? talkativeness, + bool? muted, + TavernGroupModelRole? modelRole, + }) async { + final group = _group(chatId); + final at = group.members.indexWhere((m) => m.characterId == characterId); + if (at < 0) throw TavernGroupException(TavernGroupError.memberNotFound, chatId: chatId, characterId: characterId); + if (talkativeness != null && !(talkativeness >= 0 && talkativeness <= 1)) { + throw TavernGroupException(TavernGroupError.invalidTalkativeness, chatId: chatId, characterId: characterId); + } + final old = group.members[at]; + group.members = [ + for (final (i, m) in group.members.indexed) + if (i == at) + TavernGroupMember( + characterId: old.characterId, + talkativeness: talkativeness ?? old.talkativeness, + muted: muted ?? old.muted, + modelRole: modelRole ?? old.modelRole, + ) + else + m, + ]; + return _changed(group); + } + + @override + Future removeGroupMember(String chatId, String characterId) async { + final group = _group(chatId); + if (!group.members.any((m) => m.characterId == characterId)) { + throw TavernGroupException(TavernGroupError.memberNotFound, chatId: chatId, characterId: characterId); + } + group.members = [ + for (final m in group.members) + if (m.characterId != characterId) m, + ]; + return _changed(group); + } + + @override + Future reorderGroupMembers(String chatId, List characterIds) async { + final group = _group(chatId); + final ids = List.of(characterIds); + final now = {for (final m in group.members) m.characterId: m}; + if (ids.length != now.length || !ids.every(now.containsKey) || ids.toSet().length != ids.length) { + throw TavernGroupException(TavernGroupError.invalidOrder, chatId: chatId); + } + group.members = [for (final id in ids) now[id]!]; + return _changed(group); + } + + @override + Future updateGroupChat( + String chatId, { + String? title, + TavernGroupActivation? activation, + int? maxSpeakersPerTurn, + bool? allowSelfResponses, + }) async { + final group = _group(chatId); + if (maxSpeakersPerTurn != null && maxSpeakersPerTurn < 1) { + throw TavernGroupException(TavernGroupError.invalidLimit, chatId: chatId); + } + group + ..title = title ?? group.title + ..activation = activation ?? group.activation + ..maxSpeakers = maxSpeakersPerTurn ?? group.maxSpeakers + ..allowSelf = allowSelfResponses ?? group.allowSelf; + return _changed(group); + } + + /// Who replies to [text] in [group], in order. + List _plan(_Group group, _Chat chat, String text) { + final ready = [ + for (final m in group.members) + if (!m.muted && _characters.containsKey(m.characterId)) m.characterId, + ]; + switch (group.activation) { + case TavernGroupActivation.manual: + return const []; + case TavernGroupActivation.randomPool: + // The next who has not spoken since your last line. + final spoken = {}; + for (final m in chat.path().reversed) { + if (m.role == TavernRole.user) break; + if (m.speakerCharacterId case final id?) spoken.add(id); + } + final next = ready.where((id) => !spoken.contains(id)).firstOrNull ?? ready.firstOrNull; + return [?next]; + case TavernGroupActivation.natural: + final named = ready.where((id) => text.contains(_characters[id]!.name)).toList(); + return [...named, ...ready.where((id) => !named.contains(id))].take(group.maxSpeakers).toList(); + case TavernGroupActivation.listOrder: + case TavernGroupActivation.director: + return ready.take(group.maxSpeakers).toList(); + } + } + + @override + Stream sendGroup(String chatId, String text, {String? speakerCharacterId}) { + final group = _groups[chatId]; + if (group == null) return Stream.error(TavernGroupException(TavernGroupError.notGroup, chatId: chatId)); + final out = StreamController(); + // As the engine: listening admits the batch. + out.onListen = () => unawaited(_runGroup(group, text, speakerCharacterId, out)); + return out.stream; + } + + Future _runGroup(_Group group, String text, String? explicit, StreamController out) async { + final chat = _chats[group.chatId]!; + final live = group.live = StreamController.broadcast(); + void emit(TavernGroupEvent event) { + if (!out.isClosed) out.add(event); + live.add(event); + } + + final roundId = _id('round'); + final startedAt = _clock(); + var parent = chat.tail()?.id; + String? userNode; + if (text.trim().isNotEmpty) { + final user = _Node(_id('n'), parent, TavernRole.user, 'You', text, startedAt); + chat.add(user); + chat.chosen[parent] = user.id; + parent = userNode = user.id; + chat.changes.add(null); + } + final plan = explicit != null ? [explicit] : _plan(group, chat, text); + final done = []; + TavernGroupProgress progress( + TavernGroupPhase phase, { + required List remaining, + String? speaker, + String? reply, + String? generation, + }) => group.progress = TavernGroupProgress( + chatId: group.chatId, + roundId: roundId, + startedAt: startedAt, + phase: phase, + userNodeId: userNode, + speakerCharacterId: speaker, + replyNodeId: reply, + generationId: generation, + plannedCharacterIds: plan, + remainingCharacterIds: remaining, + completedNodeIds: List.of(done), + ); + Future finish( + TavernGroupEndReason reason, + TavernGroupProgress at, { + String? attempted, + TavernMessage? partial, + }) async { + emit( + TavernGroupFinished( + chatId: group.chatId, + reason: reason, + progress: at, + attemptedCharacterId: attempted, + partial: partial, + ), + ); + group + ..live = null + ..progress = null + ..current = null; + _listChanged.add(null); + await live.close(); + if (!out.isClosed) await out.close(); + } + + emit(TavernGroupStarted(progress(TavernGroupPhase.preparing, remaining: plan))); + if (plan.isEmpty) { + await finish(TavernGroupEndReason.noSpeakers, progress(TavernGroupPhase.finished, remaining: const [])); + return; + } + emit(TavernGroupProgressChanged(progress(TavernGroupPhase.ready, remaining: plan))); + for (var i = 0; i < plan.length; i++) { + final actorId = plan[i]; + final actor = _characters[actorId]; + final rest = plan.sublist(i + 1); + if (actor == null) { + await finish( + TavernGroupEndReason.failed, + progress(TavernGroupPhase.finished, remaining: rest), + attempted: actorId, + ); + return; + } + final node = _Node(_id('n'), parent, TavernRole.character, actor.name, '', _clock())..speakerId = actorId; + chat.add(node); + chat.chosen[parent] = node.id; + group.current = node; + final speaking = progress( + TavernGroupPhase.speaking, + remaining: rest, + speaker: actorId, + reply: node.id, + generation: _id('gen'), + ); + emit(TavernGroupProgressChanged(speaking)); + TavernEvent? last; + await for (final event in _stream( + chat, + node, + i == 0 ? _cannedFor(text) : _canned[(_seq++) % _canned.length], + started: true, + )) { + last = event; + emit(TavernGroupMemberEvent(progress: speaking, characterId: actorId, event: event)); + } + if (last is! TavernTurnDone) { + await finish( + TavernGroupEndReason.cancelled, + progress(TavernGroupPhase.finished, remaining: rest), + attempted: actorId, + partial: chat.message(node), + ); + return; + } + done.add(node.id); + parent = node.id; + emit( + TavernGroupProgressChanged( + progress(TavernGroupPhase.settling, remaining: rest, speaker: actorId, reply: node.id), + ), + ); + } + await finish(TavernGroupEndReason.completed, progress(TavernGroupPhase.finished, remaining: const [])); + } + + /// The batch now (with the reply being written) and what follows, taken + /// together so nothing falls between; nothing when no batch runs. Never + /// starts one. + @override + Stream resumeGroupTurn(String chatId) { + final group = _groups[chatId]; + final live = group?.live; + final now = group?.progress; + if (group == null || live == null || now == null) return const Stream.empty(); + final current = group.current; + final out = StreamController() + ..add(TavernGroupSnapshot(progress: now, message: current == null ? null : _chats[chatId]!.message(current))); + final following = live.stream.listen(out.add, onDone: out.close); + out.onCancel = following.cancel; + return out.stream; + } + @override Future createChat(String characterId, {int greeting = 0}) async { final c = _characters[characterId]!; @@ -604,6 +964,7 @@ class PreviewTavern implements Tavern, TavernPreview { siblingCount: siblings.length, hidden: node.hidden, branchName: chat.branchNames[node.id], + speakerCharacterId: node.speakerId, ); }(), ]; @@ -1195,6 +1556,34 @@ class _Char { ]; } +/// A group chat's roster and settings, and its batch while one runs. +class _Group { + _Group(this.chatId, this.title, this.host, this.members, this.activation, this.maxSpeakers, this.allowSelf); + final String chatId; + String title; + final String host; + List members; + TavernGroupActivation activation; + int maxSpeakers; + bool allowSelf; + + /// The running batch: its events for a page coming back, where it is, and + /// the reply being written. + StreamController? live; + TavernGroupProgress? progress; + _Node? current; + + TavernGroupChat view() => TavernGroupChat( + chatId: chatId, + title: title, + hostCharacterId: host, + members: members, + activation: activation, + maxSpeakersPerTurn: maxSpeakers, + allowSelfResponses: allowSelf, + ); +} + class _Node { _Node(this.id, this.parent, this.role, this.speaker, this.text, this.at); final String id; @@ -1205,6 +1594,9 @@ class _Node { String? reasoning; final DateTime at; bool hidden = false; + + /// The library card that said it (group chats); null for you and narration. + String? speakerId; } /// A deletion's nodes, each with its place in the order, and the chosen path @@ -1264,6 +1656,7 @@ class _Chat { siblingIndex: siblings.indexOf(node), siblingCount: siblings.length, hidden: node.hidden, + speakerCharacterId: node.speakerId, ); } diff --git a/app/test/tavern/tavern_group_preview_test.dart b/app/test/tavern/tavern_group_preview_test.dart new file mode 100644 index 0000000000..e4cdac5f5e --- /dev/null +++ b/app/test/tavern/tavern_group_preview_test.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:dsh_app/tavern/deferred_tavern.dart'; +import 'package:dsh_app/tavern/tavern_preview.dart'; +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _lin = 'sample-innkeeper'; +const _star = 'sample-navigator'; + +/// What a batch said, by kind: the outer progress phases and who spoke. +String _shape(List events) => [ + for (final e in events) + switch (e) { + TavernGroupStarted() => 'started', + TavernGroupProgressChanged(:final progress) => + progress.speakerCharacterId == null + ? progress.phase.name + : '${progress.phase.name}:${progress.speakerCharacterId}', + TavernGroupMemberEvent(:final event) when event is TavernTurnDone => 'done', + TavernGroupMemberEvent() => null, + TavernGroupSnapshot() => 'snapshot', + TavernGroupFinished(:final reason) => 'finished:${reason.name}', + }, +].whereType().join(' '); + +void main() { + test('a group keeps its roster and settings; its chat is listed as a group, under each member', () async { + final tavern = PreviewTavern(tick: Duration.zero); + final chat = await tavern.createGroupChat([_lin, _star], activation: TavernGroupActivation.listOrder); + final group = (await tavern.groupChat(chat))!; + expect(group.hostCharacterId, _lin); + expect(group.members.map((m) => m.characterId), [_lin, _star]); + expect(group.maxSpeakersPerTurn, 3); + expect(group.title, '林晚、星图'); + + final summary = (await tavern.watchChats(characterId: _star).first).single; + expect(summary.kind, TavernChatKind.group); + expect(summary.memberCharacterIds, [_lin, _star]); + // Each member's greeting opens it, said by that member. + expect((await tavern.path(chat)).map((m) => m.speakerCharacterId), [_lin, _star]); + + final updated = await tavern.updateGroupMember(chat, _star, muted: true, modelRole: TavernGroupModelRole.helper); + expect(updated.members.last.muted, isTrue); + expect(updated.members.last.modelRole, TavernGroupModelRole.helper); + expect((await tavern.reorderGroupMembers(chat, [_star, _lin])).members.first.characterId, _star); + expect((await tavern.updateGroupChat(chat, title: '旅店', maxSpeakersPerTurn: 1)).title, '旅店'); + expect((await tavern.removeGroupMember(chat, _star)).members.map((m) => m.characterId), [_lin]); + + Future refused(Future call, TavernGroupError code) => + expectLater(call, throwsA(isA().having((e) => e.code, 'code', code))); + await refused(tavern.addGroupMember(chat, _lin), TavernGroupError.memberExists); + await refused(tavern.updateGroupMember(chat, _star), TavernGroupError.memberNotFound); + await refused(tavern.reorderGroupMembers(chat, [_lin, _lin]), TavernGroupError.invalidOrder); + await refused(tavern.addGroupMember(chat, _star, talkativeness: 2), TavernGroupError.invalidTalkativeness); + await refused( + tavern.groupChat('nope').then((g) => g ?? (throw TavernGroupException(TavernGroupError.notGroup))), + TavernGroupError.notGroup, + ); + }); + + test('a batch: your line once, then each planned member in turn on the shared path', () async { + final tavern = PreviewTavern(tick: Duration.zero); + final chat = await tavern.createGroupChat( + [_lin, _star], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final events = await tavern.sendGroup(chat, '大家好').toList(); + expect( + _shape(events), + 'started ready speaking:$_lin done settling:$_lin speaking:$_star done settling:$_star finished:completed', + ); + final path = await tavern.path(chat); + expect(path.map((m) => (m.role, m.speakerCharacterId)), [ + (TavernRole.user, null), + (TavernRole.character, _lin), + (TavernRole.character, _star), + ]); + final end = events.last as TavernGroupFinished; + expect(end.progress!.userNodeId, path.first.nodeId); + expect(end.progress!.completedNodeIds, [path[1].nodeId, path[2].nodeId]); + // Progress says who is next and how many are left. + final first = events.whereType().firstWhere( + (e) => e.progress.speakerCharacterId == _lin, + ); + expect(first.progress.nextCharacterId, _star); + expect(first.progress.remainingCount, 1); + }); + + test('手动 replies only when someone is named; a named member speaks even when muted', () async { + final tavern = PreviewTavern(tick: Duration.zero); + final chat = await tavern.createGroupChat( + [_lin, _star], + activation: TavernGroupActivation.manual, + addGreetings: false, + ); + await tavern.updateGroupMember(chat, _star, muted: true); + expect(_shape(await tavern.sendGroup(chat, '有人吗').toList()), 'started finished:noSpeakers'); + expect((await tavern.path(chat)).single.role, TavernRole.user); // your line stays + expect( + _shape(await tavern.sendGroup(chat, '', speakerCharacterId: _star).toList()), + 'started ready speaking:$_star done settling:$_star finished:completed', + ); + }); + + test('stop ends the whole batch: the one speaking keeps what it wrote, the rest never start', () async { + final tavern = PreviewTavern(tick: const Duration(milliseconds: 5)); + final chat = await tavern.createGroupChat( + [_lin, _star], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final events = []; + final done = Completer(); + tavern.sendGroup(chat, '开始').listen(events.add, onDone: done.complete); + // A page coming back mid-reply sees where it is, then what follows. + while (!events.any((e) => e is TavernGroupMemberEvent && e.event is TavernTextDelta)) { + await Future.delayed(const Duration(milliseconds: 2)); + } + final rejoined = tavern.resumeGroupTurn(chat).toList(); + tavern.stop(chat); + await done.future; + expect((events.last as TavernGroupFinished).reason, TavernGroupEndReason.cancelled); + expect((events.last as TavernGroupFinished).attemptedCharacterId, _lin); + expect(events.any((e) => e is TavernGroupProgressChanged && e.progress.speakerCharacterId == _star), isFalse); + final back = await rejoined; + expect(back.first, isA()); + expect((back.first as TavernGroupSnapshot).message!.speakerCharacterId, _lin); + expect(back.last, isA()); + // Nothing runs now: resuming gives nothing. + expect(await tavern.resumeGroupTurn(chat).toList(), isEmpty); + }); + + test('DeferredTavern copies the member lists when called, before the engine opens', () async { + final opening = Completer(); + final deferred = DeferredTavern(opening.future); + final ids = [_lin, _star]; + final created = deferred.createGroupChat(ids, activation: TavernGroupActivation.listOrder, addGreetings: false); + ids.removeLast(); + final preview = PreviewTavern(tick: Duration.zero); + opening.complete(preview); + final chat = await created; + expect((await deferred.groupChat(chat))!.members.map((m) => m.characterId), [_lin, _star]); + + final order = [_star, _lin]; + final reordered = deferred.reorderGroupMembers(chat, order); + order.clear(); + expect((await reordered).members.map((m) => m.characterId), [_star, _lin]); + expect(_shape(await deferred.sendGroup(chat, '').toList()), contains('finished:completed')); + expect((await deferred.watchGroupChat(chat).first)!.chatId, chat); + }); +} From 5e22cb2da8ff12650ee4b9e8e1c171b16ac7750e Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 15:57:27 +0800 Subject: [PATCH 03/51] =?UTF-8?q?feat(tavern):=20=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E2=80=94=E2=80=94=E5=BB=BA=E7=BE=A4=E3=80=81?= =?UTF-8?q?=E7=BE=A4=E8=81=8A=E8=AE=BE=E7=BD=AE=E3=80=81=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=A1=B5=E6=8C=89=E5=8F=91=E8=A8=80=E4=BA=BA=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对话 › 新建群聊(书架上有两个以上角色才能点):按点选顺序选人(第一个是主持), 群名可空(默认几个名字连起来),选怎么轮流说(自然/按顺序/随机/手动),建好直接 进对话。对话列表里群聊显示群名、群标记和人数。 - 群聊对话页:发出去的话交给整个群,成员依次流式回复,每条上面写着谁说的(各自的 名字颜色);顶栏状态写「林晚 在说 · 接着是 星图」;输入框上方一排 @名字,点了就 让那个人接着说(静音的也行);「手动」没点名时提示没人接话;回到页面接上正在跑 的一轮(resumeGroupTurn);失败时用户那句已经存下就从那里接着来。 - 点顶栏标题进群聊设置:群名、怎么轮流说、每轮最多几人、能不能接着自己说;成员的 静音、话多话少、用辅助模型、前后挪、移出,加一个角色。每个改动立刻保存。 --- app/lib/l10n/app_en.arb | 43 +++ app/lib/l10n/app_zh.arb | 92 ++++++ app/lib/l10n/gen/app_localizations.dart | 258 ++++++++++++++++ app/lib/l10n/gen/app_localizations_en.dart | 146 +++++++++ app/lib/l10n/gen/app_localizations_zh.dart | 143 +++++++++ app/lib/l10n/parts/tavern_en.arb | 52 +++- app/lib/l10n/parts/tavern_zh.arb | 52 +++- app/lib/ui/tavern/tavern_chat_page.dart | 291 ++++++++++++++++-- app/lib/ui/tavern/tavern_group_new_page.dart | 203 +++++++++++++ app/lib/ui/tavern/tavern_group_page.dart | 300 +++++++++++++++++++ app/lib/ui/tavern/tavern_home_page.dart | 36 ++- app/test/tavern/tavern_group_ui_test.dart | 165 ++++++++++ 12 files changed, 1754 insertions(+), 27 deletions(-) create mode 100644 app/lib/ui/tavern/tavern_group_new_page.dart create mode 100644 app/lib/ui/tavern/tavern_group_page.dart create mode 100644 app/test/tavern/tavern_group_ui_test.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ec9dd1d4f9..9d1cda39de 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -6400,6 +6400,49 @@ "tavernCanvas": "Whole-page canvas (trial)", "tavernCanvasSubtitle": "The whole chat in one web page laid out as SillyTavern does, so a card's ST styles reach across messages. Not for visual novel; card scripts don't run here yet.", "tavernCanvasReloaded": "The page reloaded", + "tavernGroupSpeaking": "{name} is speaking", + "tavernGroupThen": "then {names}", + "tavernGroupNoSpeakers": "No one took this turn. Tap a name above to have them speak.", + "tavernGroupSpeakNext": "Let them go on", + "tavernGroupNew": "New group chat", + "tavernGroupMembers": "Who's in it", + "tavernGroupMembersNote": "In the order picked; the first hosts it, and the story's shared state lives on that card.", + "tavernGroupHost": "Host", + "tavernGroupTitle": "Name", + "tavernGroupActivation": "Taking turns", + "tavernGroupNatural": "Natural", + "tavernGroupListOrder": "In order", + "tavernGroupRandom": "Random", + "tavernGroupManual": "Manual", + "tavernGroupDirector": "Director", + "tavernGroupNaturalNote": "As SillyTavern does: whoever is named speaks first, the rest by how talkative each is; the most per turn is in the group's settings.", + "tavernGroupListOrderNote": "Each turn goes down the list from the top (the muted are skipped).", + "tavernGroupRandomNote": "Each turn picks one who hasn't spoken since your last line.", + "tavernGroupManualNote": "No one speaks on their own; tap a name above the box to have them speak.", + "tavernGroupDirectorNote": "The director picks who speaks next, as the story goes.", + "tavernGroupPickTwo": "Pick at least two", + "tavernGroupCreate": "Create ({count})", + "tavernGroupCreateFailed": "Couldn't create the group: {reason}", + "tavernGroupTag": "Group", + "tavernGroupMemberCount": "{count} members", + "tavernGroupSettings": "Group settings", + "tavernGroupMaxSpeakers": "Most to speak each turn", + "tavernGroupSelfResponses": "May answer themselves", + "tavernGroupSelfResponsesNote": "Whoever spoke last may still go first this turn.", + "tavernGroupHostNote": "Hosted by {name}: the story's shared state, director and secrets live on that card; the host need not be among the speakers.", + "tavernGroupMuted": "Muted", + "tavernGroupMutedNote": "Won't speak on their own; still speaks when named.", + "tavernGroupTalkativeness": "Talkativeness", + "tavernGroupTalkativenessNote": "In natural turns, the higher, the likelier to speak.", + "tavernGroupHelperModel": "Helper model", + "tavernGroupHelperModelNote": "Speaks with the cheaper, faster model (DeepSeek flash, no thinking): for side characters.", + "tavernGroupMoveUp": "Move up", + "tavernGroupMoveDown": "Move down", + "tavernGroupRemove": "Remove from the group", + "tavernGroupAdd": "Add a character", + "tavernGroupNoOthers": "Everyone on the shelf is in it", + "tavernGroupUnavailable": "Card missing", + "tavernGroupSaveFailed": "Couldn't save: {reason}", "updateCheck": "Check for updates", "updateAutoCheck": "Check automatically", "updateAutoCheckDaily": "Once a day", diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index a7c5c65bb2..572d962598 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -12935,6 +12935,98 @@ "tavernCanvas": "整页画布(试用)", "tavernCanvasSubtitle": "整段对话放进一张网页,按 SillyTavern 的结构排,卡写给 ST 的样式能跨消息生效。视觉小说不用它;卡里的脚本暂时还不跑。", "tavernCanvasReloaded": "画面重新加载了", + "tavernGroupSpeaking": "{name} 在说", + "@tavernGroupSpeaking": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "tavernGroupThen": "接着是 {names}", + "@tavernGroupThen": { + "placeholders": { + "names": { + "type": "String" + } + } + }, + "tavernGroupNoSpeakers": "这一轮没人接话。点上面的名字,让谁来说。", + "tavernGroupSpeakNext": "让大家接着说", + "tavernGroupNew": "新建群聊", + "tavernGroupMembers": "谁在里面", + "tavernGroupMembersNote": "按点选的顺序排;第一个是主持,故事共用的状态记在它的卡上。", + "tavernGroupHost": "主持", + "tavernGroupTitle": "群名", + "tavernGroupActivation": "怎么轮流说", + "tavernGroupNatural": "自然", + "tavernGroupListOrder": "按顺序", + "tavernGroupRandom": "随机", + "tavernGroupManual": "手动", + "tavernGroupDirector": "导演", + "tavernGroupNaturalNote": "像 SillyTavern:被点到名的先说,其余看各自的话多话少;每轮最多几个人说在群设置里改。", + "tavernGroupListOrderNote": "每轮按名单从头轮一遍(静音的跳过)。", + "tavernGroupRandomNote": "每轮随机挑一个这轮还没说过的人。", + "tavernGroupManualNote": "谁也不自己开口;点输入框上面的名字,让谁说谁说。", + "tavernGroupDirectorNote": "由导演按剧情点名下一位。", + "tavernGroupPickTwo": "至少选两个角色", + "tavernGroupCreate": "建群({count} 人)", + "@tavernGroupCreate": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tavernGroupCreateFailed": "没能建群:{reason}", + "@tavernGroupCreateFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "tavernGroupTag": "群", + "tavernGroupMemberCount": "{count} 人", + "@tavernGroupMemberCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tavernGroupSettings": "群聊设置", + "tavernGroupMaxSpeakers": "每轮最多几人说", + "tavernGroupSelfResponses": "可以接着自己说", + "tavernGroupSelfResponsesNote": "上一句是谁说的,这一轮还能先轮到他。", + "tavernGroupHostNote": "主持是「{name}」:故事共用的状态、导演和秘密记在它的卡上;它可以不在发言名单里。", + "@tavernGroupHostNote": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "tavernGroupMuted": "静音", + "tavernGroupMutedNote": "不会自己开口;点名时照样说。", + "tavernGroupTalkativeness": "话多话少", + "tavernGroupTalkativenessNote": "「自然」轮流时,越高越容易开口。", + "tavernGroupHelperModel": "用辅助模型", + "tavernGroupHelperModelNote": "用便宜快的模型说(DeepSeek 是 flash、不思考),适合配角。", + "tavernGroupMoveUp": "往前挪", + "tavernGroupMoveDown": "往后挪", + "tavernGroupRemove": "移出群聊", + "tavernGroupAdd": "加一个角色", + "tavernGroupNoOthers": "书架上的角色都在群里了", + "tavernGroupUnavailable": "角色卡不在了", + "tavernGroupSaveFailed": "没能保存:{reason}", + "@tavernGroupSaveFailed": { + "placeholders": { + "reason": { + "type": "String" + } + } + }, "updateCheck": "检查更新", "updateAutoCheck": "自动检查更新", "updateAutoCheckDaily": "每天检查一次", diff --git a/app/lib/l10n/gen/app_localizations.dart b/app/lib/l10n/gen/app_localizations.dart index 168b9ae889..fb92af3c9c 100644 --- a/app/lib/l10n/gen/app_localizations.dart +++ b/app/lib/l10n/gen/app_localizations.dart @@ -38492,6 +38492,264 @@ abstract class L10n { /// **'画面重新加载了'** String get tavernCanvasReloaded; + /// No description provided for @tavernGroupSpeaking. + /// + /// In zh, this message translates to: + /// **'{name} 在说'** + String tavernGroupSpeaking(String name); + + /// No description provided for @tavernGroupThen. + /// + /// In zh, this message translates to: + /// **'接着是 {names}'** + String tavernGroupThen(String names); + + /// No description provided for @tavernGroupNoSpeakers. + /// + /// In zh, this message translates to: + /// **'这一轮没人接话。点上面的名字,让谁来说。'** + String get tavernGroupNoSpeakers; + + /// No description provided for @tavernGroupSpeakNext. + /// + /// In zh, this message translates to: + /// **'让大家接着说'** + String get tavernGroupSpeakNext; + + /// No description provided for @tavernGroupNew. + /// + /// In zh, this message translates to: + /// **'新建群聊'** + String get tavernGroupNew; + + /// No description provided for @tavernGroupMembers. + /// + /// In zh, this message translates to: + /// **'谁在里面'** + String get tavernGroupMembers; + + /// No description provided for @tavernGroupMembersNote. + /// + /// In zh, this message translates to: + /// **'按点选的顺序排;第一个是主持,故事共用的状态记在它的卡上。'** + String get tavernGroupMembersNote; + + /// No description provided for @tavernGroupHost. + /// + /// In zh, this message translates to: + /// **'主持'** + String get tavernGroupHost; + + /// No description provided for @tavernGroupTitle. + /// + /// In zh, this message translates to: + /// **'群名'** + String get tavernGroupTitle; + + /// No description provided for @tavernGroupActivation. + /// + /// In zh, this message translates to: + /// **'怎么轮流说'** + String get tavernGroupActivation; + + /// No description provided for @tavernGroupNatural. + /// + /// In zh, this message translates to: + /// **'自然'** + String get tavernGroupNatural; + + /// No description provided for @tavernGroupListOrder. + /// + /// In zh, this message translates to: + /// **'按顺序'** + String get tavernGroupListOrder; + + /// No description provided for @tavernGroupRandom. + /// + /// In zh, this message translates to: + /// **'随机'** + String get tavernGroupRandom; + + /// No description provided for @tavernGroupManual. + /// + /// In zh, this message translates to: + /// **'手动'** + String get tavernGroupManual; + + /// No description provided for @tavernGroupDirector. + /// + /// In zh, this message translates to: + /// **'导演'** + String get tavernGroupDirector; + + /// No description provided for @tavernGroupNaturalNote. + /// + /// In zh, this message translates to: + /// **'像 SillyTavern:被点到名的先说,其余看各自的话多话少;每轮最多几个人说在群设置里改。'** + String get tavernGroupNaturalNote; + + /// No description provided for @tavernGroupListOrderNote. + /// + /// In zh, this message translates to: + /// **'每轮按名单从头轮一遍(静音的跳过)。'** + String get tavernGroupListOrderNote; + + /// No description provided for @tavernGroupRandomNote. + /// + /// In zh, this message translates to: + /// **'每轮随机挑一个这轮还没说过的人。'** + String get tavernGroupRandomNote; + + /// No description provided for @tavernGroupManualNote. + /// + /// In zh, this message translates to: + /// **'谁也不自己开口;点输入框上面的名字,让谁说谁说。'** + String get tavernGroupManualNote; + + /// No description provided for @tavernGroupDirectorNote. + /// + /// In zh, this message translates to: + /// **'由导演按剧情点名下一位。'** + String get tavernGroupDirectorNote; + + /// No description provided for @tavernGroupPickTwo. + /// + /// In zh, this message translates to: + /// **'至少选两个角色'** + String get tavernGroupPickTwo; + + /// No description provided for @tavernGroupCreate. + /// + /// In zh, this message translates to: + /// **'建群({count} 人)'** + String tavernGroupCreate(int count); + + /// No description provided for @tavernGroupCreateFailed. + /// + /// In zh, this message translates to: + /// **'没能建群:{reason}'** + String tavernGroupCreateFailed(String reason); + + /// No description provided for @tavernGroupTag. + /// + /// In zh, this message translates to: + /// **'群'** + String get tavernGroupTag; + + /// No description provided for @tavernGroupMemberCount. + /// + /// In zh, this message translates to: + /// **'{count} 人'** + String tavernGroupMemberCount(int count); + + /// No description provided for @tavernGroupSettings. + /// + /// In zh, this message translates to: + /// **'群聊设置'** + String get tavernGroupSettings; + + /// No description provided for @tavernGroupMaxSpeakers. + /// + /// In zh, this message translates to: + /// **'每轮最多几人说'** + String get tavernGroupMaxSpeakers; + + /// No description provided for @tavernGroupSelfResponses. + /// + /// In zh, this message translates to: + /// **'可以接着自己说'** + String get tavernGroupSelfResponses; + + /// No description provided for @tavernGroupSelfResponsesNote. + /// + /// In zh, this message translates to: + /// **'上一句是谁说的,这一轮还能先轮到他。'** + String get tavernGroupSelfResponsesNote; + + /// No description provided for @tavernGroupHostNote. + /// + /// In zh, this message translates to: + /// **'主持是「{name}」:故事共用的状态、导演和秘密记在它的卡上;它可以不在发言名单里。'** + String tavernGroupHostNote(String name); + + /// No description provided for @tavernGroupMuted. + /// + /// In zh, this message translates to: + /// **'静音'** + String get tavernGroupMuted; + + /// No description provided for @tavernGroupMutedNote. + /// + /// In zh, this message translates to: + /// **'不会自己开口;点名时照样说。'** + String get tavernGroupMutedNote; + + /// No description provided for @tavernGroupTalkativeness. + /// + /// In zh, this message translates to: + /// **'话多话少'** + String get tavernGroupTalkativeness; + + /// No description provided for @tavernGroupTalkativenessNote. + /// + /// In zh, this message translates to: + /// **'「自然」轮流时,越高越容易开口。'** + String get tavernGroupTalkativenessNote; + + /// No description provided for @tavernGroupHelperModel. + /// + /// In zh, this message translates to: + /// **'用辅助模型'** + String get tavernGroupHelperModel; + + /// No description provided for @tavernGroupHelperModelNote. + /// + /// In zh, this message translates to: + /// **'用便宜快的模型说(DeepSeek 是 flash、不思考),适合配角。'** + String get tavernGroupHelperModelNote; + + /// No description provided for @tavernGroupMoveUp. + /// + /// In zh, this message translates to: + /// **'往前挪'** + String get tavernGroupMoveUp; + + /// No description provided for @tavernGroupMoveDown. + /// + /// In zh, this message translates to: + /// **'往后挪'** + String get tavernGroupMoveDown; + + /// No description provided for @tavernGroupRemove. + /// + /// In zh, this message translates to: + /// **'移出群聊'** + String get tavernGroupRemove; + + /// No description provided for @tavernGroupAdd. + /// + /// In zh, this message translates to: + /// **'加一个角色'** + String get tavernGroupAdd; + + /// No description provided for @tavernGroupNoOthers. + /// + /// In zh, this message translates to: + /// **'书架上的角色都在群里了'** + String get tavernGroupNoOthers; + + /// No description provided for @tavernGroupUnavailable. + /// + /// In zh, this message translates to: + /// **'角色卡不在了'** + String get tavernGroupUnavailable; + + /// No description provided for @tavernGroupSaveFailed. + /// + /// In zh, this message translates to: + /// **'没能保存:{reason}'** + String tavernGroupSaveFailed(String reason); + /// No description provided for @updateCheck. /// /// In zh, this message translates to: diff --git a/app/lib/l10n/gen/app_localizations_en.dart b/app/lib/l10n/gen/app_localizations_en.dart index 5e4b5c6aac..4d3e1a0fa0 100644 --- a/app/lib/l10n/gen/app_localizations_en.dart +++ b/app/lib/l10n/gen/app_localizations_en.dart @@ -22226,6 +22226,152 @@ class L10nEn extends L10n { @override String get tavernCanvasReloaded => 'The page reloaded'; + @override + String tavernGroupSpeaking(String name) { + return '$name is speaking'; + } + + @override + String tavernGroupThen(String names) { + return 'then $names'; + } + + @override + String get tavernGroupNoSpeakers => 'No one took this turn. Tap a name above to have them speak.'; + + @override + String get tavernGroupSpeakNext => 'Let them go on'; + + @override + String get tavernGroupNew => 'New group chat'; + + @override + String get tavernGroupMembers => 'Who\'s in it'; + + @override + String get tavernGroupMembersNote => + 'In the order picked; the first hosts it, and the story\'s shared state lives on that card.'; + + @override + String get tavernGroupHost => 'Host'; + + @override + String get tavernGroupTitle => 'Name'; + + @override + String get tavernGroupActivation => 'Taking turns'; + + @override + String get tavernGroupNatural => 'Natural'; + + @override + String get tavernGroupListOrder => 'In order'; + + @override + String get tavernGroupRandom => 'Random'; + + @override + String get tavernGroupManual => 'Manual'; + + @override + String get tavernGroupDirector => 'Director'; + + @override + String get tavernGroupNaturalNote => + 'As SillyTavern does: whoever is named speaks first, the rest by how talkative each is; the most per turn is in the group\'s settings.'; + + @override + String get tavernGroupListOrderNote => 'Each turn goes down the list from the top (the muted are skipped).'; + + @override + String get tavernGroupRandomNote => 'Each turn picks one who hasn\'t spoken since your last line.'; + + @override + String get tavernGroupManualNote => 'No one speaks on their own; tap a name above the box to have them speak.'; + + @override + String get tavernGroupDirectorNote => 'The director picks who speaks next, as the story goes.'; + + @override + String get tavernGroupPickTwo => 'Pick at least two'; + + @override + String tavernGroupCreate(int count) { + return 'Create ($count)'; + } + + @override + String tavernGroupCreateFailed(String reason) { + return 'Couldn\'t create the group: $reason'; + } + + @override + String get tavernGroupTag => 'Group'; + + @override + String tavernGroupMemberCount(int count) { + return '$count members'; + } + + @override + String get tavernGroupSettings => 'Group settings'; + + @override + String get tavernGroupMaxSpeakers => 'Most to speak each turn'; + + @override + String get tavernGroupSelfResponses => 'May answer themselves'; + + @override + String get tavernGroupSelfResponsesNote => 'Whoever spoke last may still go first this turn.'; + + @override + String tavernGroupHostNote(String name) { + return 'Hosted by $name: the story\'s shared state, director and secrets live on that card; the host need not be among the speakers.'; + } + + @override + String get tavernGroupMuted => 'Muted'; + + @override + String get tavernGroupMutedNote => 'Won\'t speak on their own; still speaks when named.'; + + @override + String get tavernGroupTalkativeness => 'Talkativeness'; + + @override + String get tavernGroupTalkativenessNote => 'In natural turns, the higher, the likelier to speak.'; + + @override + String get tavernGroupHelperModel => 'Helper model'; + + @override + String get tavernGroupHelperModelNote => + 'Speaks with the cheaper, faster model (DeepSeek flash, no thinking): for side characters.'; + + @override + String get tavernGroupMoveUp => 'Move up'; + + @override + String get tavernGroupMoveDown => 'Move down'; + + @override + String get tavernGroupRemove => 'Remove from the group'; + + @override + String get tavernGroupAdd => 'Add a character'; + + @override + String get tavernGroupNoOthers => 'Everyone on the shelf is in it'; + + @override + String get tavernGroupUnavailable => 'Card missing'; + + @override + String tavernGroupSaveFailed(String reason) { + return 'Couldn\'t save: $reason'; + } + @override String get updateCheck => 'Check for updates'; diff --git a/app/lib/l10n/gen/app_localizations_zh.dart b/app/lib/l10n/gen/app_localizations_zh.dart index e9e0ebc254..5ecf67be7a 100644 --- a/app/lib/l10n/gen/app_localizations_zh.dart +++ b/app/lib/l10n/gen/app_localizations_zh.dart @@ -21065,6 +21065,149 @@ class L10nZh extends L10n { @override String get tavernCanvasReloaded => '画面重新加载了'; + @override + String tavernGroupSpeaking(String name) { + return '$name 在说'; + } + + @override + String tavernGroupThen(String names) { + return '接着是 $names'; + } + + @override + String get tavernGroupNoSpeakers => '这一轮没人接话。点上面的名字,让谁来说。'; + + @override + String get tavernGroupSpeakNext => '让大家接着说'; + + @override + String get tavernGroupNew => '新建群聊'; + + @override + String get tavernGroupMembers => '谁在里面'; + + @override + String get tavernGroupMembersNote => '按点选的顺序排;第一个是主持,故事共用的状态记在它的卡上。'; + + @override + String get tavernGroupHost => '主持'; + + @override + String get tavernGroupTitle => '群名'; + + @override + String get tavernGroupActivation => '怎么轮流说'; + + @override + String get tavernGroupNatural => '自然'; + + @override + String get tavernGroupListOrder => '按顺序'; + + @override + String get tavernGroupRandom => '随机'; + + @override + String get tavernGroupManual => '手动'; + + @override + String get tavernGroupDirector => '导演'; + + @override + String get tavernGroupNaturalNote => '像 SillyTavern:被点到名的先说,其余看各自的话多话少;每轮最多几个人说在群设置里改。'; + + @override + String get tavernGroupListOrderNote => '每轮按名单从头轮一遍(静音的跳过)。'; + + @override + String get tavernGroupRandomNote => '每轮随机挑一个这轮还没说过的人。'; + + @override + String get tavernGroupManualNote => '谁也不自己开口;点输入框上面的名字,让谁说谁说。'; + + @override + String get tavernGroupDirectorNote => '由导演按剧情点名下一位。'; + + @override + String get tavernGroupPickTwo => '至少选两个角色'; + + @override + String tavernGroupCreate(int count) { + return '建群($count 人)'; + } + + @override + String tavernGroupCreateFailed(String reason) { + return '没能建群:$reason'; + } + + @override + String get tavernGroupTag => '群'; + + @override + String tavernGroupMemberCount(int count) { + return '$count 人'; + } + + @override + String get tavernGroupSettings => '群聊设置'; + + @override + String get tavernGroupMaxSpeakers => '每轮最多几人说'; + + @override + String get tavernGroupSelfResponses => '可以接着自己说'; + + @override + String get tavernGroupSelfResponsesNote => '上一句是谁说的,这一轮还能先轮到他。'; + + @override + String tavernGroupHostNote(String name) { + return '主持是「$name」:故事共用的状态、导演和秘密记在它的卡上;它可以不在发言名单里。'; + } + + @override + String get tavernGroupMuted => '静音'; + + @override + String get tavernGroupMutedNote => '不会自己开口;点名时照样说。'; + + @override + String get tavernGroupTalkativeness => '话多话少'; + + @override + String get tavernGroupTalkativenessNote => '「自然」轮流时,越高越容易开口。'; + + @override + String get tavernGroupHelperModel => '用辅助模型'; + + @override + String get tavernGroupHelperModelNote => '用便宜快的模型说(DeepSeek 是 flash、不思考),适合配角。'; + + @override + String get tavernGroupMoveUp => '往前挪'; + + @override + String get tavernGroupMoveDown => '往后挪'; + + @override + String get tavernGroupRemove => '移出群聊'; + + @override + String get tavernGroupAdd => '加一个角色'; + + @override + String get tavernGroupNoOthers => '书架上的角色都在群里了'; + + @override + String get tavernGroupUnavailable => '角色卡不在了'; + + @override + String tavernGroupSaveFailed(String reason) { + return '没能保存:$reason'; + } + @override String get updateCheck => '检查更新'; diff --git a/app/lib/l10n/parts/tavern_en.arb b/app/lib/l10n/parts/tavern_en.arb index 6002a510bd..c37abc8125 100644 --- a/app/lib/l10n/parts/tavern_en.arb +++ b/app/lib/l10n/parts/tavern_en.arb @@ -1046,5 +1046,55 @@ "tavernRegexDepthAny": "Any", "tavernCanvas": "Whole-page canvas (trial)", "tavernCanvasSubtitle": "The whole chat in one web page laid out as SillyTavern does, so a card's ST styles reach across messages. Not for visual novel; card scripts don't run here yet.", - "tavernCanvasReloaded": "The page reloaded" + "tavernCanvasReloaded": "The page reloaded", + "tavernGroupSpeaking": "{name} is speaking", + "@tavernGroupSpeaking": {"placeholders": {"name": {"type": "String"}}}, + "tavernGroupThen": "then {names}", + "@tavernGroupThen": {"placeholders": {"names": {"type": "String"}}}, + "tavernGroupNoSpeakers": "No one took this turn. Tap a name above to have them speak.", + "tavernGroupSpeakNext": "Let them go on", + "tavernGroupNew": "New group chat", + "tavernGroupMembers": "Who's in it", + "tavernGroupMembersNote": "In the order picked; the first hosts it, and the story's shared state lives on that card.", + "tavernGroupHost": "Host", + "tavernGroupTitle": "Name", + "tavernGroupActivation": "Taking turns", + "tavernGroupNatural": "Natural", + "tavernGroupListOrder": "In order", + "tavernGroupRandom": "Random", + "tavernGroupManual": "Manual", + "tavernGroupDirector": "Director", + "tavernGroupNaturalNote": "As SillyTavern does: whoever is named speaks first, the rest by how talkative each is; the most per turn is in the group's settings.", + "tavernGroupListOrderNote": "Each turn goes down the list from the top (the muted are skipped).", + "tavernGroupRandomNote": "Each turn picks one who hasn't spoken since your last line.", + "tavernGroupManualNote": "No one speaks on their own; tap a name above the box to have them speak.", + "tavernGroupDirectorNote": "The director picks who speaks next, as the story goes.", + "tavernGroupPickTwo": "Pick at least two", + "tavernGroupCreate": "Create ({count})", + "@tavernGroupCreate": {"placeholders": {"count": {"type": "int"}}}, + "tavernGroupCreateFailed": "Couldn't create the group: {reason}", + "@tavernGroupCreateFailed": {"placeholders": {"reason": {"type": "String"}}}, + "tavernGroupTag": "Group", + "tavernGroupMemberCount": "{count} members", + "@tavernGroupMemberCount": {"placeholders": {"count": {"type": "int"}}}, + "tavernGroupSettings": "Group settings", + "tavernGroupMaxSpeakers": "Most to speak each turn", + "tavernGroupSelfResponses": "May answer themselves", + "tavernGroupSelfResponsesNote": "Whoever spoke last may still go first this turn.", + "tavernGroupHostNote": "Hosted by {name}: the story's shared state, director and secrets live on that card; the host need not be among the speakers.", + "@tavernGroupHostNote": {"placeholders": {"name": {"type": "String"}}}, + "tavernGroupMuted": "Muted", + "tavernGroupMutedNote": "Won't speak on their own; still speaks when named.", + "tavernGroupTalkativeness": "Talkativeness", + "tavernGroupTalkativenessNote": "In natural turns, the higher, the likelier to speak.", + "tavernGroupHelperModel": "Helper model", + "tavernGroupHelperModelNote": "Speaks with the cheaper, faster model (DeepSeek flash, no thinking): for side characters.", + "tavernGroupMoveUp": "Move up", + "tavernGroupMoveDown": "Move down", + "tavernGroupRemove": "Remove from the group", + "tavernGroupAdd": "Add a character", + "tavernGroupNoOthers": "Everyone on the shelf is in it", + "tavernGroupUnavailable": "Card missing", + "tavernGroupSaveFailed": "Couldn't save: {reason}", + "@tavernGroupSaveFailed": {"placeholders": {"reason": {"type": "String"}}} } diff --git a/app/lib/l10n/parts/tavern_zh.arb b/app/lib/l10n/parts/tavern_zh.arb index a83e12eb6b..0ee48fae42 100644 --- a/app/lib/l10n/parts/tavern_zh.arb +++ b/app/lib/l10n/parts/tavern_zh.arb @@ -1046,5 +1046,55 @@ "tavernRegexDepthAny": "不限", "tavernCanvas": "整页画布(试用)", "tavernCanvasSubtitle": "整段对话放进一张网页,按 SillyTavern 的结构排,卡写给 ST 的样式能跨消息生效。视觉小说不用它;卡里的脚本暂时还不跑。", - "tavernCanvasReloaded": "画面重新加载了" + "tavernCanvasReloaded": "画面重新加载了", + "tavernGroupSpeaking": "{name} 在说", + "@tavernGroupSpeaking": {"placeholders": {"name": {"type": "String"}}}, + "tavernGroupThen": "接着是 {names}", + "@tavernGroupThen": {"placeholders": {"names": {"type": "String"}}}, + "tavernGroupNoSpeakers": "这一轮没人接话。点上面的名字,让谁来说。", + "tavernGroupSpeakNext": "让大家接着说", + "tavernGroupNew": "新建群聊", + "tavernGroupMembers": "谁在里面", + "tavernGroupMembersNote": "按点选的顺序排;第一个是主持,故事共用的状态记在它的卡上。", + "tavernGroupHost": "主持", + "tavernGroupTitle": "群名", + "tavernGroupActivation": "怎么轮流说", + "tavernGroupNatural": "自然", + "tavernGroupListOrder": "按顺序", + "tavernGroupRandom": "随机", + "tavernGroupManual": "手动", + "tavernGroupDirector": "导演", + "tavernGroupNaturalNote": "像 SillyTavern:被点到名的先说,其余看各自的话多话少;每轮最多几个人说在群设置里改。", + "tavernGroupListOrderNote": "每轮按名单从头轮一遍(静音的跳过)。", + "tavernGroupRandomNote": "每轮随机挑一个这轮还没说过的人。", + "tavernGroupManualNote": "谁也不自己开口;点输入框上面的名字,让谁说谁说。", + "tavernGroupDirectorNote": "由导演按剧情点名下一位。", + "tavernGroupPickTwo": "至少选两个角色", + "tavernGroupCreate": "建群({count} 人)", + "@tavernGroupCreate": {"placeholders": {"count": {"type": "int"}}}, + "tavernGroupCreateFailed": "没能建群:{reason}", + "@tavernGroupCreateFailed": {"placeholders": {"reason": {"type": "String"}}}, + "tavernGroupTag": "群", + "tavernGroupMemberCount": "{count} 人", + "@tavernGroupMemberCount": {"placeholders": {"count": {"type": "int"}}}, + "tavernGroupSettings": "群聊设置", + "tavernGroupMaxSpeakers": "每轮最多几人说", + "tavernGroupSelfResponses": "可以接着自己说", + "tavernGroupSelfResponsesNote": "上一句是谁说的,这一轮还能先轮到他。", + "tavernGroupHostNote": "主持是「{name}」:故事共用的状态、导演和秘密记在它的卡上;它可以不在发言名单里。", + "@tavernGroupHostNote": {"placeholders": {"name": {"type": "String"}}}, + "tavernGroupMuted": "静音", + "tavernGroupMutedNote": "不会自己开口;点名时照样说。", + "tavernGroupTalkativeness": "话多话少", + "tavernGroupTalkativenessNote": "「自然」轮流时,越高越容易开口。", + "tavernGroupHelperModel": "用辅助模型", + "tavernGroupHelperModelNote": "用便宜快的模型说(DeepSeek 是 flash、不思考),适合配角。", + "tavernGroupMoveUp": "往前挪", + "tavernGroupMoveDown": "往后挪", + "tavernGroupRemove": "移出群聊", + "tavernGroupAdd": "加一个角色", + "tavernGroupNoOthers": "书架上的角色都在群里了", + "tavernGroupUnavailable": "角色卡不在了", + "tavernGroupSaveFailed": "没能保存:{reason}", + "@tavernGroupSaveFailed": {"placeholders": {"reason": {"type": "String"}}} } diff --git a/app/lib/ui/tavern/tavern_chat_page.dart b/app/lib/ui/tavern/tavern_chat_page.dart index 7175d1df1e..3c30363eff 100644 --- a/app/lib/ui/tavern/tavern_chat_page.dart +++ b/app/lib/ui/tavern/tavern_chat_page.dart @@ -57,6 +57,7 @@ import '../wallpaper/wallpaper_backdrop.dart'; import 'tavern_arrive.dart'; import 'tavern_canvas_view.dart'; import 'tavern_component_view.dart'; +import 'tavern_group_page.dart'; import 'tavern_character_page.dart'; import 'tavern_dice_sheet.dart'; import 'tavern_ink.dart'; @@ -112,7 +113,13 @@ class _TavernChatPageState extends ConsumerState { TavernPage? _staged; late final Tavern _tavern = ref.read(tavernProvider); StreamSubscription? _changes; - StreamSubscription? _turn; + StreamSubscription? _turn; + + /// This chat's group (engine P2 125), when it is one: its roster and how + /// it takes turns; and, while a batch runs, who speaks and who follows. + StreamSubscription? _groupWatch; + TavernGroupChat? _group; + TavernGroupProgress? _groupProgress; /// The director's three suggestions for now (engine T18), first in the row /// over the composer; none when it has not run, or its last thought is @@ -204,8 +211,10 @@ class _TavernChatPageState extends ConsumerState { onError: (Object _) {}, ); unawaited(_reload()); - // A reply left running when the page was last closed (engine T24). - _run(() => _tavern.resumeTurn(widget.chatId), rejoin: true); + _groupWatch = _tavern + .watchGroupChat(widget.chatId) + .listen((group) => mounted ? setState(() => _group = group) : null, onError: (Object _) {}); + unawaited(_rejoin()); _voice = VoiceInputController( input: _input, bridge: ref.read(voiceBridgeProvider), @@ -224,6 +233,7 @@ class _TavernChatPageState extends ConsumerState { void dispose() { unawaited(_changes?.cancel()); unawaited(_director?.cancel()); + unawaited(_groupWatch?.cancel()); // A reply goes on without the page (engine T24) and is picked up when it // opens again; a line being written into this page's box has nowhere to go. if (_running && _drafting) _tavern.stop(widget.chatId); @@ -464,6 +474,156 @@ class _TavernChatPageState extends ConsumerState { ); } + /// A reply left running when the page was last closed (engine T24), or a + /// group's batch (P2): picked up as it is now. + Future _rejoin() async { + TavernGroupChat? group; + try { + group = await _tavern.groupChat(widget.chatId); + } on Object { + group = null; + } + if (!mounted || _turn != null) return; + if (group != null) { + setState(() => _group = group); + _runGroup(() => _tavern.resumeGroupTurn(widget.chatId), rejoin: true); + } else { + _run(() => _tavern.resumeTurn(widget.chatId), rejoin: true); + } + } + + /// A group's batch (engine P2 125): the outer events say who speaks and who + /// follows; each member's own turn streams as a reply does, one after the + /// other; the batch ends once, at its Finished. + void _runGroup(Stream Function() start, {String? draft, bool rejoin = false}) { + unawaited(_turn?.cancel()); + _immersive.value = false; + _rejoining = rejoin; + if (!rejoin) { + setState(() { + _error = null; + _action = null; + _streamNode = null; + _groupProgress = null; + }); + } + _stream.value = _nothing; + _live.reset(); + _stopThinking(); + _thinking.value = null; + void member(TavernEvent event) { + switch (event) { + case TavernTurnStarted(:final message): + _stream.value = _nothing; + _live.reset(); + _stopThinking(); + _thinking.value = null; + setState(() => _streamNode = message.nodeId); + unawaited(_reload()); + case TavernTextDelta(:final nodeId, :final text): + _adopt(nodeId); + _stream.value = (text: _stream.value.text + text, reasoning: _stream.value.reasoning); + _live.text = _stream.value.text; + _stopThinking(); + case TavernReasoningDelta(:final nodeId, :final text): + _adopt(nodeId); + _stream.value = (text: _stream.value.text, reasoning: _stream.value.reasoning + text); + if (_thinking.value == null && _stream.value.text.isEmpty) { + _thinking.value = 0; + _thinkingTick = Timer.periodic(const Duration(seconds: 1), (_) => _thinking.value = _thinking.value! + 1); + } + case TavernTurnDone(): + // This one has said it; the next follows. + _stream.value = _nothing; + _live.reset(); + _stopThinking(); + setState(() => _streamNode = null); + unawaited(_reload()); + default: + // A member's failure ends the batch: its Finished says how. + } + } + + _turn = start().listen( + (event) { + if (!mounted) return; + switch (event) { + case TavernGroupStarted(:final progress) || TavernGroupProgressChanged(:final progress): + setState(() { + _rejoining = false; + _groupProgress = progress; + }); + case TavernGroupSnapshot(:final progress, :final message): + setState(() { + _rejoining = false; + _groupProgress = progress; + _streamNode = message?.nodeId; + }); + if (message != null) { + _stream.value = (text: message.text, reasoning: message.reasoning ?? ''); + _live.text = message.text; + } + case TavernGroupMemberEvent(:final progress, :final event): + _groupProgress = progress; + member(event); + case TavernGroupFinished(:final reason, :final failure, :final detail, :final progress): + setState(() => _groupProgress = null); + final l10n = context.l10n; + switch (reason) { + case TavernGroupEndReason.completed || TavernGroupEndReason.cancelled: + _finish(); + case TavernGroupEndReason.noSpeakers: + _finish(error: l10n.tavernGroupNoSpeakers, calm: true); + case TavernGroupEndReason.failed: + // Your line was not kept: back in the box. + final kept = progress?.userNodeId != null; + if (!kept && draft != null && _input.text.isEmpty) _input.text = draft; + final kind = failure ?? TavernFailure.server; + _finish( + error: _failureText(kind, detail), + action: switch (kind) { + TavernFailure.noKey || + TavernFailure.badKey => (label: l10n.commonSettings, onPressed: _openKeySettings), + // Kept: the group goes on from it. Not kept: send it again. + _ => ( + label: l10n.commonRetry, + onPressed: kept || draft == null + ? () => _runGroup(() => _tavern.sendGroup(widget.chatId, '')) + : _send, + ), + }, + ); + } + } + }, + onDone: () { + if (!mounted || _turn == null) return; + if (_rejoining) { + setState(() { + _turn = null; + _rejoining = false; + }); + } else { + _finish(); + } + }, + ); + } + + /// 点名 (a group): [characterId] replies next, to what is in the box or to + /// nothing; muted or not. + void _speak(String characterId) { + if (_running) return; + if (_tavern is! TavernPreview && !ref.read(settingsProvider).hasApiKey) { + _openKeySettings(); + return; + } + unawaited(HapticFeedback.selectionClick()); + final text = _input.text; + _input.clear(); + _runGroup(() => _tavern.sendGroup(widget.chatId, text, speakerCharacterId: characterId), draft: text); + } + /// 小说: into the story by the reader's own hand hides the header and the /// box; back toward the newest lines, or near them, shows them again. Not /// while a reply is written or something is being typed. @@ -539,6 +699,11 @@ class _TavernChatPageState extends ConsumerState { } final text = _input.text; _input.clear(); + // A group: the line goes to all of them, who answer in turn. + if (_group != null) { + _runGroup(() => _tavern.sendGroup(widget.chatId, text), draft: text); + return; + } _run(() => _tavern.send(widget.chatId, text), draft: text); } @@ -648,6 +813,10 @@ class _TavernChatPageState extends ConsumerState { void _sendQuick(String text) { if (_running) return; unawaited(HapticFeedback.selectionClick()); + if (_group != null) { + _runGroup(() => _tavern.sendGroup(widget.chatId, text), draft: text); + return; + } _run(() => _tavern.send(widget.chatId, text), draft: text); } @@ -1002,7 +1171,10 @@ class _TavernChatPageState extends ConsumerState { ?.characterId; final characters = ref.watch(tavernCharactersProvider).value; final character = characters?.where((c) => c.id == characterId).firstOrNull; - final name = character?.name ?? l10n.tavernTitle; + // A group goes by its own title; its members by theirs. + final group = _group; + final names = {for (final c in characters ?? const []) c.id: c.name}; + final name = group?.title ?? character?.name ?? l10n.tavernTitle; final preview = _tavern is TavernPreview; // The card's own look (酒馆设置 can turn it off): a layout to start in, // a picture, an accent and name colours for the story. @@ -1016,6 +1188,15 @@ class _TavernChatPageState extends ConsumerState { final showUsage = ref.watch(tavernSettingsProvider.select((s) => s.showUsage)); final messages = _messages; final (String status, StatusTone tone) = switch (()) { + // A group: who speaks now, and who follows. + _ when _running && _groupProgress?.speakerCharacterId != null => ( + [ + l10n.tavernGroupSpeaking(names[_groupProgress!.speakerCharacterId] ?? ''), + if (_groupProgress!.remainingCharacterIds.isNotEmpty) + l10n.tavernGroupThen([for (final id in _groupProgress!.remainingCharacterIds) names[id] ?? id].join('、')), + ].join(' · '), + StatusTone.busy, + ), _ when _running => (l10n.tavernReplying, StatusTone.busy), _ when preview => (l10n.tavernPreviewStatus, StatusTone.warn), // What the card pins (好感 8 · 地点 旅店) in place of 「就绪」. @@ -1039,6 +1220,8 @@ class _TavernChatPageState extends ConsumerState { live: _live, thinking: _thinking, indent: indent, + speaker: group != null && !novel ? m.speaker : null, + lead: name, ) else _MessageRow( @@ -1054,6 +1237,7 @@ class _TavernChatPageState extends ConsumerState { showUsage: showUsage, lead: name, bookmarked: marked.contains(m.nodeId), + showName: group != null, ), ], if (_running && (_streamNode == null || !(messages?.any((m) => m.nodeId == _streamNode) ?? false))) @@ -1125,9 +1309,14 @@ class _TavernChatPageState extends ConsumerState { status: status, tone: tone, scrolledUnder: _scrolled, - // The title is the character's: its page. The chat's own things are in 舞台 › 本局. + // The title is the character's: its page; a group's, its settings. + // The chat's own things are in 舞台 › 本局. onDetails: () { - if (characterId != null) unawaited(Navigator.of(context).push(TavernCharacterPage.route(characterId))); + if (group != null) { + unawaited(Navigator.of(context).push(TavernGroupPage.route(widget.chatId))); + } else if (characterId != null) { + unawaited(Navigator.of(context).push(TavernCharacterPage.route(characterId))); + } }, // 舞台 in place of ⓘ (the title opens the quick look); beside the story // on a wide screen, it needs no key. @@ -1293,7 +1482,7 @@ class _TavernChatPageState extends ConsumerState { hardwareEnterSends: ref.watch(settingsProvider.select((s) => s.hardwareEnterSends)), // Nothing written: the character speaks next (engine send with no line). sendWhenEmpty: messages != null && messages.isNotEmpty, - emptySendLabel: l10n.tavernSpeakNext(name), + emptySendLabel: group != null ? l10n.tavernGroupSpeakNext : l10n.tavernSpeakNext(name), controller: _input, focusNode: _focus, connected: messages != null, @@ -1312,6 +1501,11 @@ class _TavernChatPageState extends ConsumerState { ? _SlashRow(onPick: _pickSlash) : !_running && messages != null && (!_readingBack.value || _focus.hasFocus) ? _QuickReplyRow( + members: [ + for (final m in group?.members ?? const []) + if (names[m.characterId] case final n?) (m.characterId, n), + ], + onSpeak: _speak, directed: _directed, onDirected: _suggest, onDirectedSend: _sendQuick, @@ -1416,11 +1610,15 @@ class _MessageRow extends StatelessWidget { this.showUsage = false, this.lead = '', this.bookmarked = false, + this.showName = false, }); final String chatId; final TavernMessage message; + /// A group: each character's line says whose it is (bubbles too). + final bool showName; + /// Marked (书签): says so under it, as hidden does. final bool bookmarked; final bool last; @@ -1501,6 +1699,19 @@ class _MessageRow extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (showName && message.role == TavernRole.character) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + tavernSpeaker(context, message), + key: ValueKey('tavern-speaker-${message.nodeId}'), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: tavernNameColor(context, tavernSpeaker(context, message), lead: lead), + fontWeight: FontWeight.w600, + fontFamily: tavernNameFont(context), + ), + ), + ), // Narration says so: it is no one's line. if (message.role == TavernRole.narrator) Padding( @@ -1633,6 +1844,8 @@ class _SlashRow extends StatelessWidget { /// it in the box), then 掷骰. class _QuickReplyRow extends StatelessWidget { const _QuickReplyRow({ + this.members = const [], + this.onSpeak, this.directed = const [], this.onDirected, this.onDirectedSend, @@ -1643,6 +1856,10 @@ class _QuickReplyRow extends StatelessWidget { required this.onAll, }); + /// A group's members, before all else: a tap has that one speak next. + final List<(String, String)> members; + final ValueChanged? onSpeak; + /// The director's suggestions, first: a tap puts one in the box; a long /// press sends it as it is (docs/design/roleplay-ui.md §5). final List directed; @@ -1672,30 +1889,40 @@ class _QuickReplyRow extends StatelessWidget { key: const ValueKey('tavern-quick-replies'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 4), - itemCount: directed.length + replies.length + (replies.isEmpty ? 1 : 2), + itemCount: members.length + directed.length + replies.length + (replies.isEmpty ? 1 : 2), separatorBuilder: (_, _) => const SizedBox(width: 8), // The director's suggestions arrive whenever it has them: each new // one fades in, a beat after the one before it. - itemBuilder: (context, at) => at < directed.length - ? TavernArrive( - key: ValueKey(('tavern-directed-arrive', at, directed[at])), - index: at, - child: GestureDetector( - onLongPress: onDirectedSend == null ? null : () => onDirectedSend!(directed[at]), - child: CapsuleChoiceChip( - key: ValueKey('tavern-directed-$at'), - icon: AppIcons.askAi, - label: _label(directed[at]), - selected: false, - onSelected: () => onDirected?.call(directed[at]), - ), - ), + itemBuilder: (context, i) => i < members.length + ? CapsuleChoiceChip( + key: ValueKey('tavern-speak-${members[i].$1}'), + icon: AppIcons.mention, + label: members[i].$2, + selected: false, + onSelected: () => onSpeak?.call(members[i].$1), ) - : _item(context, at - directed.length), + : _directedOrReply(context, i - members.length), ), ), ); + Widget _directedOrReply(BuildContext context, int at) => at < directed.length + ? TavernArrive( + key: ValueKey(('tavern-directed-arrive', at, directed[at])), + index: at, + child: GestureDetector( + onLongPress: onDirectedSend == null ? null : () => onDirectedSend!(directed[at]), + child: CapsuleChoiceChip( + key: ValueKey('tavern-directed-$at'), + icon: AppIcons.askAi, + label: _label(directed[at]), + selected: false, + onSelected: () => onDirected?.call(directed[at]), + ), + ), + ) + : _item(context, at - directed.length); + Widget _item(BuildContext context, int i) => i > replies.length ? CapsuleChoiceChip( key: const ValueKey('tavern-qr-all'), @@ -2018,8 +2245,14 @@ class _StreamingReply extends StatelessWidget { required this.live, required this.thinking, this.indent = false, + this.speaker, + this.lead = '', }); + /// A group: who is writing it, over it. + final String? speaker; + final String lead; + /// 小说's paragraph indent, as the reply will have once written. final bool indent; @@ -2038,6 +2271,18 @@ class _StreamingReply extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (speaker case final who? when who.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + who, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: tavernNameColor(context, who, lead: lead), + fontWeight: FontWeight.w600, + fontFamily: tavernNameFont(context), + ), + ), + ), if (reasoning.isNotEmpty) ThinkingPill( reasoning: reasoning, diff --git a/app/lib/ui/tavern/tavern_group_new_page.dart b/app/lib/ui/tavern/tavern_group_new_page.dart new file mode 100644 index 0000000000..bbbe0ce2ab --- /dev/null +++ b/app/lib/ui/tavern/tavern_group_new_page.dart @@ -0,0 +1,203 @@ +// 对话 › 新建群聊 (engine P2 125, roleplay-agent.md §4): pick who is in it, +// in the order they first speak; a name if you like (theirs, joined, if +// not); and how they take turns. The first picked hosts it: their card holds +// the story's shared state. + +import 'dart:async'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/l10n.dart'; +import '../../tavern/tavern_provider.dart'; +import '../features/feature_kit.dart'; +import '../providers/provider_widgets.dart'; +import '../settings/settings_widgets.dart'; +import '../shell/page_frame.dart'; +import '../theme/app_icons.dart'; +import '../theme/theme.dart'; +import 'tavern_chat_page.dart'; + +/// How a group takes turns, as the reader picks it; 导演 is the director's +/// call, chosen from the group's own settings once it has one. +String tavernGroupActivationName(BuildContext context, TavernGroupActivation activation) { + final l10n = context.l10n; + return switch (activation) { + TavernGroupActivation.natural => l10n.tavernGroupNatural, + TavernGroupActivation.listOrder => l10n.tavernGroupListOrder, + TavernGroupActivation.randomPool => l10n.tavernGroupRandom, + TavernGroupActivation.manual => l10n.tavernGroupManual, + TavernGroupActivation.director => l10n.tavernGroupDirector, + }; +} + +/// What each way of taking turns does, in a line. +String tavernGroupActivationNote(BuildContext context, TavernGroupActivation activation) { + final l10n = context.l10n; + return switch (activation) { + TavernGroupActivation.natural => l10n.tavernGroupNaturalNote, + TavernGroupActivation.listOrder => l10n.tavernGroupListOrderNote, + TavernGroupActivation.randomPool => l10n.tavernGroupRandomNote, + TavernGroupActivation.manual => l10n.tavernGroupManualNote, + TavernGroupActivation.director => l10n.tavernGroupDirectorNote, + }; +} + +class TavernGroupNewPage extends ConsumerStatefulWidget { + const TavernGroupNewPage({super.key}); + + static Route route() => MaterialPageRoute(builder: (_) => const TavernGroupNewPage()); + + @override + ConsumerState createState() => _TavernGroupNewPageState(); +} + +class _TavernGroupNewPageState extends ConsumerState { + final _title = TextEditingController(); + + /// Picked, in the order picked: who speaks first, and who hosts. + final _picked = []; + TavernGroupActivation _activation = TavernGroupActivation.natural; + bool _creating = false; + String? _error; + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + void _toggle(String id) => setState(() { + if (!_picked.remove(id)) _picked.add(id); + }); + + Future _create() async { + if (_creating || _picked.length < 2) return; + final navigator = Navigator.of(context); + final l10n = context.l10n; + setState(() { + _creating = true; + _error = null; + }); + try { + final title = _title.text.trim(); + final chatId = await ref + .read(tavernProvider) + .createGroupChat(List.of(_picked), title: title.isEmpty ? null : title, activation: _activation); + if (!mounted) return; + unawaited(navigator.pushReplacement(TavernChatPage.route(chatId))); + } on Object catch (e) { + if (mounted) { + setState(() { + _creating = false; + _error = l10n.tavernGroupCreateFailed('$e'); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final dsh = DshColors.of(context); + final characters = ref.watch(tavernCharactersProvider).value; + return Stack( + children: [ + AppPage.list( + title: l10n.tavernGroupNew, + children: [ + if (characters == null) + const QuietSpinner() + else ...[ + SettingsSection( + title: l10n.tavernGroupMembers, + footer: Text(l10n.tavernGroupMembersNote), + children: [ + for (final c in characters) + SettingsTile( + key: ValueKey('tavern-group-pick-${c.id}'), + tinted: false, + title: c.name, + value: switch (_picked.indexOf(c.id)) { + -1 => null, + 0 => Text(l10n.tavernGroupHost), + final n => Text('${n + 1}'), + }, + trailing: Icon( + _picked.contains(c.id) ? AppIcons.checkboxOn : AppIcons.checkboxOff, + color: _picked.contains(c.id) ? theme.colorScheme.primary : dsh.textTertiary, + ), + chevron: false, + onTap: _creating ? null : () => _toggle(c.id), + ), + ], + ), + SettingsSection( + children: [ + FormFieldGroup( + children: [ + FormFieldBlock( + label: l10n.tavernGroupTitle, + child: TextField( + key: const ValueKey('tavern-group-title'), + controller: _title, + enabled: !_creating, + decoration: InputDecoration( + hintText: [ + for (final id in _picked) + if (characters.where((c) => c.id == id).firstOrNull case final c?) c.name, + ].join('、'), + ), + ), + ), + ], + ), + ], + ), + SettingsSection( + footer: Text(tavernGroupActivationNote(context, _activation)), + children: [ + SettingsSegmentTile( + key: const ValueKey('tavern-group-activation'), + title: l10n.tavernGroupActivation, + segments: [ + for (final a in const [ + TavernGroupActivation.natural, + TavernGroupActivation.listOrder, + TavernGroupActivation.randomPool, + TavernGroupActivation.manual, + ]) + PillSegment(a, tavernGroupActivationName(context, a)), + ], + selected: _activation, + onChanged: (a) => setState(() => _activation = a), + ), + ], + ), + if (_error case final error?) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text(error, style: theme.textTheme.bodySmall?.copyWith(color: dsh.danger)), + ), + const SizedBox(height: FormBottomBar.height), + ], + ], + ), + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.viewInsetsOf(context).bottom, + child: FormBottomBar( + child: FilledButton( + key: const ValueKey('tavern-group-create'), + onPressed: _creating || _picked.length < 2 ? null : () => unawaited(_create()), + child: Text(_picked.length < 2 ? l10n.tavernGroupPickTwo : l10n.tavernGroupCreate(_picked.length)), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/ui/tavern/tavern_group_page.dart b/app/lib/ui/tavern/tavern_group_page.dart new file mode 100644 index 0000000000..64b47220ba --- /dev/null +++ b/app/lib/ui/tavern/tavern_group_page.dart @@ -0,0 +1,300 @@ +// 群聊设置 (engine P2 125, roleplay-agent.md §4.1): the group's name; how it +// takes turns and how many speak at most each turn; and its members — each +// muted or not, more or less talkative, on the main model or the helper, in +// order, taken out, or one more added. Every change is saved at once; the +// page follows the group as the engine has it. + +import 'dart:async'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/l10n.dart'; +import '../../tavern/tavern_provider.dart'; +import '../common/app_sheet.dart'; +import '../common/brief_notice.dart'; +import '../features/feature_kit.dart'; +import '../settings/settings_widgets.dart'; +import '../shell/page_frame.dart'; +import '../theme/app_icons.dart'; +import '../workspace/text_prompt.dart'; +import 'tavern_group_new_page.dart'; +import 'tavern_stepper.dart'; + +/// A group as the engine has it now; null once it is not one. +final tavernGroupProvider = StreamProvider.autoDispose.family( + (ref, chatId) => ref.watch(tavernProvider).watchGroupChat(chatId), +); + +class TavernGroupPage extends ConsumerWidget { + const TavernGroupPage({super.key, required this.chatId}); + + final String chatId; + + static Route route(String chatId) => MaterialPageRoute(builder: (_) => TavernGroupPage(chatId: chatId)); + + /// Saves one change; a refusal says why. + Future _save(BuildContext context, WidgetRef ref, Future Function(Tavern t) change) async { + final messenger = ScaffoldMessenger.maybeOf(context); + final l10n = context.l10n; + try { + await change(ref.read(tavernProvider)); + } on TavernGroupException catch (e) { + messenger?.showBriefNotice(l10n.tavernGroupSaveFailed(e.code.name)); + } on Object catch (e) { + messenger?.showBriefNotice(l10n.tavernGroupSaveFailed('$e')); + } + } + + Future _rename(BuildContext context, WidgetRef ref, TavernGroupChat group) async { + final l10n = context.l10n; + final title = await showTextPrompt(context, title: l10n.tavernGroupTitle, initial: group.title, maxLength: 60); + if (title == null || title.trim().isEmpty || !context.mounted) return; + await _save(context, ref, (t) => t.updateGroupChat(chatId, title: title.trim())); + } + + Future _add(BuildContext context, WidgetRef ref, TavernGroupChat group) async { + final l10n = context.l10n; + final inside = {for (final m in group.members) m.characterId}; + final others = [ + for (final c in ref.read(tavernCharactersProvider).value ?? const []) + if (!inside.contains(c.id)) c, + ]; + final picked = await showAppSheet( + context, + isScrollControlled: true, + builder: (sheet) => SafeArea( + top: false, + child: SingleChildScrollView( + child: SheetGroup( + title: l10n.tavernGroupAdd, + margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), + children: [ + if (others.isEmpty) + SettingsTile(tinted: false, title: l10n.tavernGroupNoOthers, chevron: false) + else + for (final c in others) + SettingsTile( + key: ValueKey('tavern-group-add-${c.id}'), + icon: AppIcons.addMember, + tinted: false, + title: c.name, + chevron: false, + onTap: () => Navigator.of(sheet).pop(c.id), + ), + ], + ), + ), + ), + ); + if (picked == null || !context.mounted) return; + await _save(context, ref, (t) => t.addGroupMember(chatId, picked)); + } + + /// One member's own settings, each saved as it changes. + Future _member(BuildContext context, WidgetRef ref, String characterId, String name) async { + final l10n = context.l10n; + await showAppSheet( + context, + isScrollControlled: true, + builder: (sheet) => Consumer( + builder: (context, ref, _) { + final group = ref.watch(tavernGroupProvider(chatId)).value; + final members = group?.members ?? const []; + final at = members.indexWhere((m) => m.characterId == characterId); + if (group == null || at < 0) return const SizedBox.shrink(); + final member = members[at]; + return SafeArea( + top: false, + child: SingleChildScrollView( + child: SheetGroup( + title: name, + margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), + children: [ + SettingsSwitchTile( + key: const ValueKey('tavern-group-member-muted'), + title: l10n.tavernGroupMuted, + subtitle: l10n.tavernGroupMutedNote, + value: member.muted, + onChanged: (on) => + unawaited(_save(context, ref, (t) => t.updateGroupMember(chatId, characterId, muted: on))), + ), + SettingsTile( + key: const ValueKey('tavern-group-member-talk'), + title: l10n.tavernGroupTalkativeness, + subtitle: l10n.tavernGroupTalkativenessNote, + value: TavernStepper( + keyName: 'tavern-group-member-talk', + value: (member.talkativeness * 100).round(), + max: 100, + step: 10, + suffix: '%', + onChanged: (v) => unawaited( + _save(context, ref, (t) => t.updateGroupMember(chatId, characterId, talkativeness: v / 100)), + ), + ), + chevron: false, + ), + SettingsSwitchTile( + key: const ValueKey('tavern-group-member-helper'), + title: l10n.tavernGroupHelperModel, + subtitle: l10n.tavernGroupHelperModelNote, + value: member.modelRole == TavernGroupModelRole.helper, + onChanged: (on) => unawaited( + _save( + context, + ref, + (t) => t.updateGroupMember( + chatId, + characterId, + modelRole: on ? TavernGroupModelRole.helper : TavernGroupModelRole.main, + ), + ), + ), + ), + if (at > 0) + SettingsTile( + key: const ValueKey('tavern-group-member-up'), + icon: AppIcons.scrollToTop, + tinted: false, + title: l10n.tavernGroupMoveUp, + chevron: false, + onTap: () => unawaited(_move(context, ref, members, at, at - 1)), + ), + if (at < members.length - 1) + SettingsTile( + key: const ValueKey('tavern-group-member-down'), + icon: AppIcons.scrollToBottom, + tinted: false, + title: l10n.tavernGroupMoveDown, + chevron: false, + onTap: () => unawaited(_move(context, ref, members, at, at + 1)), + ), + SettingsTile( + key: const ValueKey('tavern-group-member-remove'), + icon: AppIcons.removeMember, + danger: true, + tinted: false, + title: l10n.tavernGroupRemove, + chevron: false, + onTap: () { + Navigator.of(sheet).pop(); + unawaited(_save(context, ref, (t) => t.removeGroupMember(chatId, characterId))); + }, + ), + ], + ), + ), + ); + }, + ), + ); + } + + Future _move(BuildContext context, WidgetRef ref, List members, int from, int to) { + final ids = [for (final m in members) m.characterId]; + final id = ids.removeAt(from); + ids.insert(to, id); + return _save(context, ref, (t) => t.reorderGroupMembers(chatId, ids)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final group = ref.watch(tavernGroupProvider(chatId)).value; + final names = { + for (final c in ref.watch(tavernCharactersProvider).value ?? const []) c.id: c.name, + }; + return AppPage.list( + title: l10n.tavernGroupSettings, + children: [ + if (group == null) + const QuietSpinner() + else ...[ + SettingsSection( + children: [ + SettingsTile( + key: const ValueKey('tavern-group-rename'), + icon: AppIcons.rename, + title: l10n.tavernGroupTitle, + value: Text(group.title), + onTap: () => unawaited(_rename(context, ref, group)), + ), + ], + ), + SettingsSection( + footer: Text(tavernGroupActivationNote(context, group.activation)), + children: [ + SettingsSegmentTile( + key: const ValueKey('tavern-group-activation'), + title: l10n.tavernGroupActivation, + segments: [ + for (final a in [ + TavernGroupActivation.natural, + TavernGroupActivation.listOrder, + TavernGroupActivation.randomPool, + TavernGroupActivation.manual, + // The director's way shows once the group takes it. + if (group.activation == TavernGroupActivation.director) TavernGroupActivation.director, + ]) + PillSegment(a, tavernGroupActivationName(context, a)), + ], + selected: group.activation, + onChanged: (a) => unawaited(_save(context, ref, (t) => t.updateGroupChat(chatId, activation: a))), + ), + SettingsTile( + key: const ValueKey('tavern-group-max'), + title: l10n.tavernGroupMaxSpeakers, + value: TavernStepper( + keyName: 'tavern-group-max', + value: group.maxSpeakersPerTurn, + min: 1, + max: 6, + onChanged: (n) => + unawaited(_save(context, ref, (t) => t.updateGroupChat(chatId, maxSpeakersPerTurn: n))), + ), + chevron: false, + ), + SettingsSwitchTile( + key: const ValueKey('tavern-group-self'), + title: l10n.tavernGroupSelfResponses, + subtitle: l10n.tavernGroupSelfResponsesNote, + value: group.allowSelfResponses, + onChanged: (on) => + unawaited(_save(context, ref, (t) => t.updateGroupChat(chatId, allowSelfResponses: on))), + ), + ], + ), + SettingsSection( + title: l10n.tavernGroupMembers, + footer: Text(l10n.tavernGroupHostNote(names[group.hostCharacterId] ?? '')), + children: [ + for (final m in group.members) + SettingsTile( + key: ValueKey('tavern-group-member-${m.characterId}'), + tinted: false, + title: names[m.characterId] ?? m.characterId, + subtitle: [ + if (m.characterId == group.hostCharacterId) l10n.tavernGroupHost, + if (m.muted) l10n.tavernGroupMuted, + if (m.modelRole == TavernGroupModelRole.helper) l10n.tavernGroupHelperModel, + if (!m.available) l10n.tavernGroupUnavailable, + ].join(' · '), + value: Text('${(m.talkativeness * 100).round()}%'), + onTap: () => unawaited(_member(context, ref, m.characterId, names[m.characterId] ?? m.characterId)), + ), + SettingsTile( + key: const ValueKey('tavern-group-add'), + icon: AppIcons.addMember, + title: l10n.tavernGroupAdd, + chevron: false, + onTap: () => unawaited(_add(context, ref, group)), + ), + ], + ), + ], + ], + ); + } +} diff --git a/app/lib/ui/tavern/tavern_home_page.dart b/app/lib/ui/tavern/tavern_home_page.dart index 00c932c998..4de2caef55 100644 --- a/app/lib/ui/tavern/tavern_home_page.dart +++ b/app/lib/ui/tavern/tavern_home_page.dart @@ -27,6 +27,7 @@ import '../theme/theme.dart'; import 'tavern_character_edit_page.dart'; import 'tavern_character_page.dart'; import 'tavern_chat_page.dart'; +import 'tavern_group_new_page.dart'; import 'tavern_lorebooks_page.dart'; import 'tavern_personas.dart'; import 'tavern_presets_page.dart'; @@ -337,8 +338,24 @@ class _TavernHomePageState extends ConsumerState { /// 对话: every chat, the newest first. List _chats(List chats, Map byId) { final l10n = context.l10n; + // 新建群聊: two characters or more on the shelf. + final newGroup = SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + key: const ValueKey('tavern-group-new'), + icon: const Icon(AppIcons.newGroupChat, size: 18), + label: Text(l10n.tavernGroupNew), + onPressed: byId.length < 2 ? null : () => unawaited(Navigator.of(context).push(TavernGroupNewPage.route())), + ), + ), + ), + ); if (chats.isEmpty) { return [ + newGroup, SliverFillRemaining( hasScrollBody: false, child: EmptyView( @@ -363,6 +380,7 @@ class _TavernHomePageState extends ConsumerState { chat, ]; return [ + newGroup, if (chats.length >= TavernHomePage.searchFrom) SliverToBoxAdapter( child: Padding( @@ -474,7 +492,8 @@ class _ChatRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final dsh = DshColors.of(context); - final name = character?.name ?? chat.title; + final group = chat.kind == TavernChatKind.group; + final name = group ? chat.title : character?.name ?? chat.title; return GlassSurface( key: ValueKey('tavern-chat-row-${chat.id}'), tier: GlassTier.card, @@ -501,6 +520,15 @@ class _ChatRow extends ConsumerWidget { children: [ Row( children: [ + if (group) ...[ + Icon( + AppIcons.groupChat, + key: ValueKey('tavern-chat-group-${chat.id}'), + size: 14, + color: dsh.textTertiary, + ), + const SizedBox(width: 4), + ], Expanded( child: Text( name, @@ -511,7 +539,11 @@ class _ChatRow extends ConsumerWidget { ), const SizedBox(width: 6), Text( - '${context.l10n.tavernMessageCount(chat.messageCount)} · ${shortTime(chat.updatedAt)}', + [ + if (group) context.l10n.tavernGroupMemberCount(chat.memberCharacterIds.length), + context.l10n.tavernMessageCount(chat.messageCount), + shortTime(chat.updatedAt), + ].join(' · '), style: AppType.tabular(theme.textTheme.labelSmall!.copyWith(color: dsh.textTertiary)), ), ], diff --git a/app/test/tavern/tavern_group_ui_test.dart b/app/test/tavern/tavern_group_ui_test.dart new file mode 100644 index 0000000000..c9f0a6471a --- /dev/null +++ b/app/test/tavern/tavern_group_ui_test.dart @@ -0,0 +1,165 @@ +import 'package:dsh_app/tavern/tavern_preview.dart'; +import 'package:dsh_app/tavern/tavern_provider.dart'; +import 'package:dsh_app/ui/tavern/tavern_chat_page.dart'; +import 'package:dsh_app/ui/tavern/tavern_group_page.dart'; +import 'package:dsh_app/ui/tavern/tavern_home_page.dart'; +import 'package:dsh_app/ui/theme/app_icons.dart'; +import 'package:dsh_app/ui/theme/theme.dart'; +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../support/test_fonts.dart'; + +const _lin = 'sample-innkeeper'; +const _star = 'sample-navigator'; + +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 200; i++) { + await tester.pump(const Duration(milliseconds: 5)); + } + await tester.pumpAndSettle(); +} + +Future _pump(WidgetTester tester, PreviewTavern tavern, Widget home) async { + tester.view.physicalSize = const Size(390, 1100) * 2; + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [tavernProvider.overrideWith((ref) => tavern)], + child: MaterialApp( + theme: withTestFonts(buildLightTheme()), + locale: const Locale('zh', 'CN'), + supportedLocales: const [Locale('zh', 'CN'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: home, + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _group( + WidgetTester tester, + PreviewTavern tavern, { + TavernGroupActivation activation = TavernGroupActivation.listOrder, +}) async => + (await tester.runAsync(() => tavern.createGroupChat([_lin, _star], activation: activation, addGreetings: false)))!; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a line in a group: each member answers in turn, under their own name', (tester) async { + final tavern = PreviewTavern(tick: const Duration(milliseconds: 1)); + final chat = await _group(tester, tavern); + await _pump(tester, tavern, TavernChatPage(chatId: chat)); + // The group's own title over the story; its members to name, over the box. + expect(find.text('林晚、星图'), findsWidgets); + expect(find.byKey(const ValueKey('tavern-speak-$_lin')), findsOneWidget); + expect(find.byKey(const ValueKey('tavern-speak-$_star')), findsOneWidget); + + await tester.enterText(find.byType(TextField), '大家好'); + await tester.pump(); + await tester.tap(find.byIcon(AppIcons.send)); + await _settle(tester); + + final path = (await tester.runAsync(() => tavern.path(chat)))!; + expect(path.map((m) => m.speakerCharacterId), [null, _lin, _star]); + expect(find.byKey(ValueKey('tavern-speaker-${path[1].nodeId}')), findsOneWidget); + expect(find.byKey(ValueKey('tavern-speaker-${path[2].nodeId}')), findsOneWidget); + expect( + find.descendant(of: find.byKey(ValueKey('tavern-speaker-${path[2].nodeId}')), matching: find.text('星图')), + findsNothing, + ); // the key is on the name's own Text + expect(tester.widget(find.byKey(ValueKey('tavern-speaker-${path[2].nodeId}'))).data, '星图'); + }); + + testWidgets('naming a member has that one speak, muted or not; 手动 alone says no one spoke', (tester) async { + final tavern = PreviewTavern(tick: const Duration(milliseconds: 1)); + final chat = await _group(tester, tavern, activation: TavernGroupActivation.manual); + await tester.runAsync(() => tavern.updateGroupMember(chat, _star, muted: true)); + await _pump(tester, tavern, TavernChatPage(chatId: chat)); + + await tester.enterText(find.byType(TextField), '有人吗'); + await tester.pump(); + await tester.tap(find.byIcon(AppIcons.send)); + await _settle(tester); + expect(find.textContaining('这一轮没人接话'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('tavern-speak-$_star'))); + await _settle(tester); + final path = (await tester.runAsync(() => tavern.path(chat)))!; + expect(path.map((m) => (m.role, m.speakerCharacterId)), [(TavernRole.user, null), (TavernRole.character, _star)]); + }); + + testWidgets('新建群聊 from 对话: pick two, name it or not, and its chat opens', (tester) async { + final tavern = PreviewTavern(tick: Duration.zero); + await _pump(tester, tavern, const TavernHomePage()); + await tester.tap(find.descendant(of: find.byKey(const ValueKey('tavern-sections')), matching: find.text('对话'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('tavern-group-new'))); + await tester.pumpAndSettle(); + + // Not yet: one is not a group. + await tester.tap(find.byKey(const ValueKey('tavern-group-pick-$_star'))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(const ValueKey('tavern-group-create'))).onPressed, isNull); + await tester.tap(find.byKey(const ValueKey('tavern-group-pick-$_lin'))); + await tester.pumpAndSettle(); + expect(find.text('建群(2 人)'), findsOneWidget); + await tester.enterText(find.byKey(const ValueKey('tavern-group-title')), '夜航'); + await tester.tap(find.byKey(const ValueKey('tavern-group-create'))); + await tester.pumpAndSettle(); + + expect(find.byType(TavernChatPage), findsOneWidget); + final chats = (await tester.runAsync(() => tavern.watchChats().first))!; + final made = chats.singleWhere((c) => c.kind == TavernChatKind.group); + expect(made.title, '夜航'); + // Picked first, the host; and first to speak. + expect(made.characterId, _star); + expect(made.memberCharacterIds, [_star, _lin]); + }); + + testWidgets('群聊设置: how it takes turns, how many, and each member', (tester) async { + final tavern = PreviewTavern(tick: Duration.zero); + final chat = await _group(tester, tavern); + await _pump(tester, tavern, TavernGroupPage(chatId: chat)); + + await tester.tap( + find.descendant(of: find.byKey(const ValueKey('tavern-group-activation')), matching: find.text('随机')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('tavern-group-max-down'))); + await tester.pumpAndSettle(); + var group = (await tester.runAsync(() => tavern.groupChat(chat)))!; + expect(group.activation, TavernGroupActivation.randomPool); + expect(group.maxSpeakersPerTurn, 2); + + await tester.tap(find.byKey(const ValueKey('tavern-group-member-$_star'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('tavern-group-member-muted'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('tavern-group-member-up'))); + await tester.pumpAndSettle(); + group = (await tester.runAsync(() => tavern.groupChat(chat)))!; + expect(group.members.map((m) => m.characterId), [_star, _lin]); + expect(group.members.first.muted, isTrue); + await tester.tap(find.byKey(const ValueKey('tavern-group-member-remove'))); + await tester.pumpAndSettle(); + group = (await tester.runAsync(() => tavern.groupChat(chat)))!; + expect(group.members.map((m) => m.characterId), [_lin]); + expect(find.text('静音'), findsNothing); + + // One more, from the shelf. + await tester.tap(find.byKey(const ValueKey('tavern-group-add'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('tavern-group-add-$_star'))); + await tester.pumpAndSettle(); + group = (await tester.runAsync(() => tavern.groupChat(chat)))!; + expect(group.members.map((m) => m.characterId), [_lin, _star]); + }); +} From a4e8e6f098db5d1744540dac75b5b6bbc641edec Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 16:15:29 +0800 Subject: [PATCH 04/51] =?UTF-8?q?feat(tavern):=20=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E5=B0=8F=E8=AF=B4=E6=8E=92=E7=89=88=E9=87=8C=EF=BC=8C=E7=BE=A4?= =?UTF-8?q?=E8=81=8A=E7=9A=84=E8=88=9E=E5=8F=B0=E6=8D=A2=E6=88=90=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E8=AF=B4=E8=AF=9D=E7=9A=84=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群聊用视觉小说排版时,舞台上站的是这一页(往前找最近一条角色台词)的说话人:用 他自己的卡面和表情图,换人时淡入淡出;你说话的那页留着上一个说话的人。群聊里不 用某一个人的 Live2D 代表全员。名字牌本来就按发言人显示。 --- app/lib/ui/tavern/tavern_chat_page.dart | 10 ++++- app/lib/ui/tavern/tavern_vn_view.dart | 47 ++++++++++++++++++----- app/test/tavern/tavern_sprites_test.dart | 48 ++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/app/lib/ui/tavern/tavern_chat_page.dart b/app/lib/ui/tavern/tavern_chat_page.dart index 3c30363eff..02fabfa6d6 100644 --- a/app/lib/ui/tavern/tavern_chat_page.dart +++ b/app/lib/ui/tavern/tavern_chat_page.dart @@ -1252,7 +1252,9 @@ class _TavernChatPageState extends ConsumerState { // Read here, not in the 视觉小说 builder below, which reruns on every // piece of the reply. - final live2d = layout != TavernLayout.visualNovel || characterId == null || _stageFailed + // A group's stage shows whoever speaks, in their own picture: no one + // member's Live2D model stands for all of them. + final live2d = layout != TavernLayout.visualNovel || characterId == null || _stageFailed || group != null ? null : ref.watch(tavernLive2dProvider(characterId)).value; final revealRate = ref.watch(tavernSettingsProvider.select((s) => s.textSpeed.charactersPerSecond)); @@ -1366,6 +1368,12 @@ class _TavernChatPageState extends ConsumerState { name: name, avatarPath: character?.avatarPath, sprites: tavernSprites(character?.assets), + castFor: group == null + ? null + : (id) => switch (characters?.where((c) => c.id == id).firstOrNull) { + final c? => (name: c.name, avatarPath: c.avatarPath, sprites: tavernSprites(c.assets)), + null => null, + }, stage: switch (live2d) { final store? => CompanionStage( key: ValueKey(store.root.path), diff --git a/app/lib/ui/tavern/tavern_vn_view.dart b/app/lib/ui/tavern/tavern_vn_view.dart index ec5e86c0c2..5ce0fd62d7 100644 --- a/app/lib/ui/tavern/tavern_vn_view.dart +++ b/app/lib/ui/tavern/tavern_vn_view.dart @@ -145,6 +145,9 @@ List tavernParagraphPages(String paragraph) { return pages.where((p) => p.isNotEmpty).toList(); } +/// One of a group's cast, as the stage shows them: name, picture, sprites. +typedef TavernVnCast = ({String name, String? avatarPath, Map sprites}); + class TavernVisualNovelView extends StatefulWidget { const TavernVisualNovelView({ super.key, @@ -159,10 +162,15 @@ class TavernVisualNovelView extends StatefulWidget { this.onPage, this.onReadingBack, this.revealRate, + this.castFor, }); final List pages; + /// A group (engine P2): who a member is, by their card's ID; the stage + /// shows whoever spoke last, in their own picture and sprites. + final TavernVnCast? Function(String characterId)? castFor; + /// The character on stage. final String name; final String? avatarPath; @@ -197,20 +205,40 @@ class _TavernVisualNovelViewState extends State with Sing /// The sprite for page [at]: by her words on it, or on her page before /// yours; her neutral one before she has said anything. String? _spriteAt(int at) { - if (widget.sprites.isEmpty) return null; + final sprites = _castAt(at).sprites; + if (sprites.isEmpty) return null; for (var i = at; i >= 0 && i < widget.pages.length; i--) { final page = widget.pages[i]; if (page.message.role == TavernRole.character) { - return tavernSpriteFor(widget.sprites, page.text, marker: page.marker); + return tavernSpriteFor(sprites, page.text, marker: page.marker); } } - return tavernSpriteFor(widget.sprites, ''); + return tavernSpriteFor(sprites, ''); } - Widget _face() => AspectRatio( - key: const ValueKey('tavern-vn-face'), - aspectRatio: 2 / 3, - child: TavernPortrait(name: widget.name, path: widget.avatarPath, radius: AppShape.xl, initialSize: 96), + /// Who stands on the stage at page [at]: the speaker of the nearest + /// character page at or before it, in a group; else the chat's character. + TavernVnCast _castAt(int at) { + final own = (name: widget.name, avatarPath: widget.avatarPath, sprites: widget.sprites); + final castFor = widget.castFor; + if (castFor == null) return own; + for (var i = at; i >= 0 && i < widget.pages.length; i--) { + final message = widget.pages[i].message; + if (message.role != TavernRole.character) continue; + final id = message.speakerCharacterId; + return (id == null ? null : castFor(id)) ?? own; + } + return own; + } + + /// [cast]'s picture; keyed by who it is, so a new speaker cross-fades in. + Widget _face(TavernVnCast cast) => KeyedSubtree( + key: ValueKey(('tavern-vn-cast', cast.name)), + child: AspectRatio( + key: const ValueKey('tavern-vn-face'), + aspectRatio: 2 / 3, + child: TavernPortrait(name: cast.name, path: cast.avatarPath, radius: AppShape.xl, initialSize: 96), + ), ); /// The page shown; null follows the newest. @@ -400,6 +428,7 @@ class _TavernVisualNovelViewState extends State with Sing _schedule(page, at); final stage = widget.stage; final sprite = stage == null ? _spriteAt(at) : null; + final cast = _castAt(at); return Stack( children: [ Positioned.fill( @@ -445,10 +474,10 @@ class _TavernVisualNovelViewState extends State with Sing File(sprite), fit: BoxFit.contain, alignment: Alignment.bottomCenter, - errorBuilder: (_, _, _) => Center(child: _face()), + errorBuilder: (_, _, _) => Center(child: _face(cast)), ), ) - : _face(), + : _face(cast), ), ), ), diff --git a/app/test/tavern/tavern_sprites_test.dart b/app/test/tavern/tavern_sprites_test.dart index 5b7a3248d2..ead9767912 100644 --- a/app/test/tavern/tavern_sprites_test.dart +++ b/app/test/tavern/tavern_sprites_test.dart @@ -11,18 +11,24 @@ import '../support/test_fonts.dart'; const _sprites = {'joy': '/s/joy.png', 'sadness': '/s/sad.png', 'neutral': '/s/neutral.png'}; -TavernPage _page(String id, TavernRole role, String text) => TavernPage( +TavernPage _page(String id, TavernRole role, String text, {String speaker = '林晚', String? speakerId}) => TavernPage( TavernMessage( nodeId: id, role: role, - speaker: role == TavernRole.user ? 'User' : '林晚', + speaker: role == TavernRole.user ? 'User' : speaker, text: text, at: DateTime(2026), + speakerCharacterId: speakerId, ), text, ); -Future _show(WidgetTester tester, List pages, Map sprites) async { +Future _show( + WidgetTester tester, + List pages, + Map sprites, { + TavernVnCast? Function(String id)? castFor, +}) async { tester.view.physicalSize = const Size(390, 844) * 2; tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); @@ -42,6 +48,7 @@ Future _show(WidgetTester tester, List pages, Map null, sprites: sprites, + castFor: castFor, ), ), ), @@ -107,6 +114,41 @@ void main() { expect(find.byKey(const ValueKey('/s/joy.png')), findsOneWidget); }); + testWidgets('a group: the stage shows whoever spoke, in their own sprites; back, the one before', (tester) async { + TavernVnCast? cast(String id) => switch (id) { + 'lin' => (name: '林晚', avatarPath: null, sprites: _sprites), + 'star' => (name: '星图', avatarPath: null, sprites: const {'joy': '/t/joy.png', 'neutral': '/t/neutral.png'}), + _ => null, + }; + await _show( + tester, + [ + _page('a', TavernRole.character, '她低下头,眼泪掉了下来。', speakerId: 'lin'), + _page('b', TavernRole.character, '他笑着递过来一杯茶。', speaker: '星图', speakerId: 'star'), + _page('c', TavernRole.user, '你们俩怎么了?'), + ], + _sprites, + castFor: cast, + ); + // Your page keeps the last speaker on stage: 星图, smiling. + expect(find.byKey(const ValueKey('/t/joy.png')), findsOneWidget); + await _back(tester); + expect(find.byKey(const ValueKey('/t/joy.png')), findsOneWidget); + await _back(tester); + expect(find.byKey(const ValueKey('/s/sad.png')), findsOneWidget); + expect(find.byKey(const ValueKey('/t/joy.png')), findsNothing); + + // Someone with no sprites stands as their card face. + await _show( + tester, + [_page('d', TavernRole.character, '风停了。', speaker: '路人', speakerId: 'nobody')], + const {}, + castFor: (id) => (name: '路人', avatarPath: null, sprites: const {}), + ); + expect(find.byKey(const ValueKey('tavern-vn-face')), findsOneWidget); + expect(find.byKey(const ValueKey(('tavern-vn-cast', '路人'))), findsOneWidget); + }); + testWidgets('no sprites: the card face, as before', (tester) async { await _show(tester, [_page('a', TavernRole.character, '她笑着递过来一杯茶。')], const {}); expect(find.byKey(const ValueKey('tavern-vn-face')), findsOneWidget); From 5274473f4aff490283f373565f7e9f7c39118311 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 21:19:55 +0800 Subject: [PATCH 05/51] =?UTF-8?q?fix(tavern):=20=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=E9=87=8C=E6=AD=A3=E5=9C=A8=E5=86=99=E7=9A=84=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=E7=BD=B2=E8=AF=B4=E8=AF=9D=E7=9A=84=E6=88=90=E5=91=98=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=98=AF=E7=BE=A4=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整页画布、视觉小说和气泡里还没落盘的回复原来都写群名(林晚、星图)。 画布的流式名字直接读成员事件给的进度(它先于重建到),换人时页面里那条也改名。 --- app/lib/tavern/tavern_canvas.dart | 1 + app/lib/ui/tavern/tavern_chat_page.dart | 18 ++++++++- app/test/tavern/tavern_canvas_test.dart | 52 +++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/app/lib/tavern/tavern_canvas.dart b/app/lib/tavern/tavern_canvas.dart index 16ec88da68..b9c67c604a 100644 --- a/app/lib/tavern/tavern_canvas.dart +++ b/app/lib/tavern/tavern_canvas.dart @@ -266,6 +266,7 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple var stay=nearBottom(); if(html===null){if(stream){stream.remove();stream=null;}return;} if(!stream){stream=mes({mesid:chat.children.length,node:'',name:name,html:'',user:false,system:false});stream.dataset.streaming='1';chat.appendChild(stream);} + else if(stream.getAttribute('ch_name')!==(name||'')){stream.setAttribute('ch_name',name||'');stream.querySelector('.name_text').textContent=name||'';} stream.querySelector('.mes_text').innerHTML=html; if(stay)toBottom(); } diff --git a/app/lib/ui/tavern/tavern_chat_page.dart b/app/lib/ui/tavern/tavern_chat_page.dart index 02fabfa6d6..0422410067 100644 --- a/app/lib/ui/tavern/tavern_chat_page.dart +++ b/app/lib/ui/tavern/tavern_chat_page.dart @@ -153,13 +153,19 @@ class _TavernChatPageState extends ConsumerState { /// null while nothing is being written. Fed only while the canvas shows. final _canvasStream = ValueNotifier<(String, String)?>(null); bool _canvasOn = false; + + /// The chat's character (or a group's title), and the names of all. String _canvasSpeaker = ''; + Map _canvasNames = const {}; void _feedCanvas() { if (!_canvasOn) return; final text = _live.shown; + // In a group, the member speaking as of their own events, which come + // before the page rebuilds for them. + final speaker = _canvasNames[_group == null ? null : _groupProgress?.speakerCharacterId] ?? _canvasSpeaker; _canvasStream.value = _running && !_drafting && text.isNotEmpty - ? (_canvasSpeaker, tavernStoryHtml(tavernComponentTagsOut(tavernStripMarkers(text)))) + ? (speaker, tavernStoryHtml(tavernComponentTagsOut(tavernStripMarkers(text)))) : null; } @@ -1175,6 +1181,10 @@ class _TavernChatPageState extends ConsumerState { final group = _group; final names = {for (final c in characters ?? const []) c.id: c.name}; final name = group?.title ?? character?.name ?? l10n.tavernTitle; + // Who writes the reply now: in a group, the member speaking, not the + // group's title; else the chat's character. + final speaking = group == null ? null : _groupProgress?.speakerCharacterId; + final writer = names[speaking] ?? name; final preview = _tavern is TavernPreview; // The card's own look (酒馆设置 can turn it off): a layout to start in, // a picture, an accent and name colours for the story. @@ -1247,6 +1257,8 @@ class _TavernChatPageState extends ConsumerState { live: _live, thinking: _thinking, indent: indent, + speaker: group != null && !novel ? writer : null, + lead: name, ), ]; @@ -1262,6 +1274,7 @@ class _TavernChatPageState extends ConsumerState { final canvas = layout != TavernLayout.visualNovel && ref.watch(tavernSettingsProvider.select((s) => s.canvas)); _canvasOn = canvas; _canvasSpeaker = name; + _canvasNames = names; if (canvas) _feedCanvas(); // The pages and the canvas read each message as shown, through the // display regex; the bubble and novel rows ask for their own. @@ -1357,7 +1370,8 @@ class _TavernChatPageState extends ConsumerState { TavernMessage( nodeId: _streamNode ?? '', role: TavernRole.character, - speaker: name, + speaker: writer, + speakerCharacterId: speaking, text: '', at: DateTime(0), ), diff --git a/app/test/tavern/tavern_canvas_test.dart b/app/test/tavern/tavern_canvas_test.dart index d60fabb157..2c9b5450ba 100644 --- a/app/test/tavern/tavern_canvas_test.dart +++ b/app/test/tavern/tavern_canvas_test.dart @@ -6,7 +6,9 @@ import 'package:dsh_app/tavern/tavern_provider.dart'; import 'package:dsh_app/tavern/tavern_settings.dart'; import 'package:dsh_app/ui/tavern/tavern_canvas_view.dart'; import 'package:dsh_app/ui/tavern/tavern_chat_page.dart'; +import 'package:dsh_app/ui/theme/app_icons.dart'; import 'package:dsh_app/ui/theme/theme.dart'; +import 'package:dsh_tavern/dsh_tavern.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -24,11 +26,27 @@ class _Canvas { var builds = 0; } -Future<(PreviewTavern, String, _Canvas)> _chat(WidgetTester tester, {Map prefs = const {}}) async { +Future<(PreviewTavern, String, _Canvas)> _chat( + WidgetTester tester, { + Map prefs = const {}, + bool group = false, + Duration tick = Duration.zero, +}) async { SharedPreferences.setMockInitialValues({TavernSettingsController.canvasKey: true, ...prefs}); - final tavern = PreviewTavern(tick: Duration.zero); - final chat = (await tester.runAsync(() => tavern.createChat('sample-innkeeper')))!; - await tester.runAsync(() => tavern.narrate(chat, '*雨停了。*\n\n"进来吧," 她说。')); + final tavern = PreviewTavern(tick: tick); + final String chat; + if (group) { + chat = (await tester.runAsync( + () => tavern.createGroupChat( + ['sample-innkeeper', 'sample-navigator'], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ), + ))!; + } else { + chat = (await tester.runAsync(() => tavern.createChat('sample-innkeeper')))!; + await tester.runAsync(() => tavern.narrate(chat, '*雨停了。*\n\n"进来吧," 她说。')); + } tester.view.physicalSize = const Size(390, 844) * 2; tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); @@ -148,6 +166,32 @@ void main() { expect(find.text('复制'), findsOneWidget); }); + testWidgets('in a group, the reply being written goes under whoever speaks now', (tester) async { + final (tavern, chat, canvas) = await _chat(tester, group: true, tick: const Duration(milliseconds: 1)); + final writers = {}; + void seen() { + if (canvas.streaming!.value case (final name, _)) writers.add(name); + } + + canvas.streaming!.addListener(seen); + addTearDown(() => canvas.streaming!.removeListener(seen)); + await tester.enterText(find.byType(TextField), '大家好'); + await tester.pump(); + await tester.tap(find.byIcon(AppIcons.send)); + for (var i = 0; i < 200; i++) { + await tester.pump(const Duration(milliseconds: 5)); + } + await tester.pumpAndSettle(); + + // Each member under their own name; never the group's title (林晚、星图). + expect(writers, containsAll(['林晚', '星图'])); + expect(writers, isNot(contains('林晚、星图'))); + final path = (await tester.runAsync(() => tavern.path(chat)))!; + expect(path.map((m) => m.speakerCharacterId), [null, 'sample-innkeeper', 'sample-navigator']); + expect(canvas.entries.skip(1).map((e) => e.name), ['林晚', '星图']); + expect(canvas.streaming!.value, isNull); + }); + testWidgets('视觉小说 keeps its own view', (tester) async { await _chat(tester, prefs: {TavernSettingsController.layoutKey: TavernLayout.visualNovel.name}); expect(find.byKey(const ValueKey('fake-canvas')), findsNothing); From ceccd8ec9d230e5e972830e21aab78efe395d78b Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 22:18:57 +0800 Subject: [PATCH 06/51] =?UTF-8?q?perf(tavern):=20=E6=95=B4=E9=A1=B5?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=8F=AA=E9=87=8D=E7=94=BB=E5=8F=98=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E6=B6=88=E6=81=AF=EF=BC=8C=E6=97=A7=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E5=B1=8F=E4=B8=8A=E6=97=B6=E4=B8=8D=E6=8E=92?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render() 原来每次都清空重建整条故事;现在按节点认出没变的消息,原样留着 (卡放在里面的 HTML 不会因为来了新消息而重画),只换变了的、补新的、删多的。 最新 20 条之外的消息加 content-visibility:auto,滚出屏幕时浏览器跳过它们的 排版和绘制。在 headless Edge 里用 300 条核过:保留、改一条、删中间一条、 变短、流式那条清掉、far 标记和 content-visibility 都对。 --- app/lib/tavern/tavern_canvas.dart | 24 ++++++++++++++++++++++-- app/test/tavern/tavern_canvas_test.dart | 5 +++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/lib/tavern/tavern_canvas.dart b/app/lib/tavern/tavern_canvas.dart index b9c67c604a..266804f9dd 100644 --- a/app/lib/tavern/tavern_canvas.dart +++ b/app/lib/tavern/tavern_canvas.dart @@ -167,6 +167,10 @@ String tavernStoryHtml(String text) { String _css(Color c) => 'rgba(${(c.r * 255).round()},${(c.g * 255).round()},${(c.b * 255).round()},${c.a.toStringAsFixed(3)})'; +/// How many of the latest messages the canvas always lays out; older ones +/// are skipped while off screen (CSS content-visibility). +const tavernCanvasNear = 20; + /// The page the canvas loads once: ST's structure and colour variables, the /// story's size and colours, and our script (allowed by [nonce]) that draws /// what render()/stream() hand it and reports a long press on a message to @@ -213,6 +217,7 @@ html,body{margin:0;padding:0;background:transparent;} body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple-system,"Noto Sans CJK SC",sans-serif;overflow-wrap:anywhere;-webkit-text-size-adjust:100%;} #chat{display:flex;flex-direction:column;gap:14px;padding:12px 16px 24px;} .mes{display:flex;flex-direction:column;} +.mes.far{content-visibility:auto;contain-intrinsic-size:auto 160px;} .mes .ch_name{font-size:.82em;font-weight:600;color:var(--dsh-accent);margin-bottom:2px;} .mes[is_user="true"] .ch_name{color:var(--dsh-muted);} .mes[is_user="true"] .mes_text{background:var(--dsh-user);border-radius:16px;padding:8px 12px;align-self:flex-end;max-width:85%;box-sizing:border-box;} @@ -236,6 +241,7 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple var userLabel=${jsonEncode(userLabel)}; function nearBottom(){return window.innerHeight+window.scrollY>=document.body.scrollHeight-80;} function toBottom(){window.scrollTo(0,document.body.scrollHeight);} + function sig(e){return JSON.stringify([e.name||'',!!e.user,!!e.system,e.html||'']);} function mes(e){ var m=document.createElement('div'); m.className='mes'; @@ -258,8 +264,22 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple window.dsh={ render:function(list){ var stay=nearBottom()||!chat.firstChild; - chat.textContent='';stream=null; - for(var i=0;ilist.length)chat.lastElementChild.remove(); if(stay)toBottom(); }, stream:function(name,html){ diff --git a/app/test/tavern/tavern_canvas_test.dart b/app/test/tavern/tavern_canvas_test.dart index 2c9b5450ba..78fdc5b131 100644 --- a/app/test/tavern/tavern_canvas_test.dart +++ b/app/test/tavern/tavern_canvas_test.dart @@ -117,6 +117,11 @@ void main() { ); final open = page(); expect(open, contains('
')); + // A long story: only the latest messages are always laid out, and one + // drawn as it was is kept (the script itself was checked in Edge). + expect(open, contains('.mes.far{content-visibility:auto;')); + expect(open, contains("m.classList.toggle('far',i Date: Thu, 12 Nov 2026 22:26:10 +0800 Subject: [PATCH 07/51] =?UTF-8?q?feat(tavern):=20=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E5=B0=8F=E8=AF=B4=E7=9A=84=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E9=87=8C=EF=BC=8C=E7=BE=A4=E8=81=8A=E6=AF=8F=E5=8F=A5=E7=BD=B2?= =?UTF-8?q?=E8=AF=B4=E8=AF=9D=E7=9A=84=E6=88=90=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录页原来只有一个名字,群聊里所有角色的话都署群名(林晚、星图)。 CompanionLogEntry.her 加可选的 who / tone:酒馆按 tavernSpeaker 署名 (旁白也照聊天写「旁白」),颜色同聊天里的 tavernNameColor;换人就重新 标名字。伙伴小屋不传,照旧。 --- app/lib/ui/companion/companion_log.dart | 30 ++++++++++++----- app/lib/ui/tavern/tavern_vn_view.dart | 15 ++++++++- app/test/tavern/tavern_sprites_test.dart | 42 +++++++++++++++++++++++- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/app/lib/ui/companion/companion_log.dart b/app/lib/ui/companion/companion_log.dart index 01ab0e0b09..864f953775 100644 --- a/app/lib/ui/companion/companion_log.dart +++ b/app/lib/ui/companion/companion_log.dart @@ -30,11 +30,14 @@ const companionLogYouTone = Color(0xFF9FD8F2); /// One line of the log. @immutable class CompanionLogEntry { - const CompanionLogEntry.her(this.text) : speaker = CompanionLogSpeaker.her, code = false; - const CompanionLogEntry.you(this.text) : speaker = CompanionLogSpeaker.you, code = false; + const CompanionLogEntry.her(this.text, {this.who, this.tone}) : speaker = CompanionLogSpeaker.her, code = false; + const CompanionLogEntry.you(this.text) : speaker = CompanionLogSpeaker.you, code = false, who = null, tone = null; /// What she did, not said: [text] is the verb and what it was done to. - const CompanionLogEntry.did(this.text, {this.code = false}) : speaker = CompanionLogSpeaker.narration; + const CompanionLogEntry.did(this.text, {this.code = false}) + : speaker = CompanionLogSpeaker.narration, + who = null, + tone = null; final CompanionLogSpeaker speaker; final String text; @@ -42,12 +45,22 @@ class CompanionLogEntry { /// Narration whose subject is a command or a path (set in monospace). final bool code; + /// Who says a line of hers when it is someone other than [CompanionLog.name] + /// (the tavern: one of a group, the narrator), and their colour; null: her. + final String? who; + final Color? tone; + @override bool operator ==(Object other) => - other is CompanionLogEntry && other.speaker == speaker && other.text == text && other.code == code; + other is CompanionLogEntry && + other.speaker == speaker && + other.text == text && + other.code == code && + other.who == who && + other.tone == tone; @override - int get hashCode => Object.hash(speaker, text, code); + int get hashCode => Object.hash(speaker, text, code, who, tone); @override String toString() => '${speaker.name}: $text'; @@ -297,7 +310,7 @@ class _CompanionLogState extends State { herTone: widget.herTone, youTone: widget.youTone, // A speaker's name only where the speaker changes. - named: before == null || before.speaker != entry.speaker, + named: before == null || before.speaker != entry.speaker || before.who != entry.who, first: before == null, ); }, @@ -409,7 +422,8 @@ class _LogLine extends StatelessWidget { ); } final her = entry.speaker == CompanionLogSpeaker.her; - final who = her ? name : l10n.companionLogYou; + final who = her ? (entry.who ?? name) : l10n.companionLogYou; + final tone = her ? (entry.tone ?? herTone) : youTone; return Padding( padding: EdgeInsets.only(top: gap), child: Semantics( @@ -424,7 +438,7 @@ class _LogLine extends StatelessWidget { child: Text( who, style: theme.textTheme.labelLarge?.copyWith( - color: her ? herTone : youTone, + color: tone, fontWeight: FontWeight.w600, letterSpacing: 0.6, ), diff --git a/app/lib/ui/tavern/tavern_vn_view.dart b/app/lib/ui/tavern/tavern_vn_view.dart index 5ce0fd62d7..bc9790b272 100644 --- a/app/lib/ui/tavern/tavern_vn_view.dart +++ b/app/lib/ui/tavern/tavern_vn_view.dart @@ -619,7 +619,7 @@ class _TavernVisualNovelViewState extends State with Sing if (page.message.role == TavernRole.user) CompanionLogEntry.you(_plain(page.text)) else - CompanionLogEntry.her(_plain(page.text)), + _logLine(context, page.message, _plain(page.text), lead: widget.name), ], name: widget.name, onClose: () => setState(() => _log = false), @@ -711,3 +711,16 @@ class _BoxKey extends StatelessWidget { /// A page as the backlog shows it: plain text, Markdown emphasis dropped. String _plain(String text) => text.replaceAll(RegExp(r'(\*\*|\*|__)'), ''); + +/// A line not yours in the backlog, under whoever says it: in a group each +/// member (the name over the story is the group's), the narrator as 旁白; +/// named and coloured as in the chat. +CompanionLogEntry _logLine(BuildContext context, TavernMessage message, String text, {required String lead}) { + final who = tavernSpeaker(context, message); + if (who.isEmpty || who == lead) return CompanionLogEntry.her(text); + return CompanionLogEntry.her( + text, + who: who, + tone: tavernNameColor(context, who, lead: lead), + ); +} diff --git a/app/test/tavern/tavern_sprites_test.dart b/app/test/tavern/tavern_sprites_test.dart index ead9767912..aaf5c55798 100644 --- a/app/test/tavern/tavern_sprites_test.dart +++ b/app/test/tavern/tavern_sprites_test.dart @@ -1,4 +1,5 @@ import 'package:dsh_app/tavern/tavern_sprites.dart'; +import 'package:dsh_app/ui/companion/companion_log.dart'; import 'package:dsh_app/ui/tavern/tavern_vn_view.dart'; import 'package:dsh_app/ui/theme/theme.dart'; import 'package:dsh_tavern/dsh_tavern.dart'; @@ -28,6 +29,7 @@ Future _show( List pages, Map sprites, { TavernVnCast? Function(String id)? castFor, + String name = '林晚', }) async { tester.view.physicalSize = const Size(390, 844) * 2; tester.view.devicePixelRatio = 2; @@ -42,7 +44,7 @@ Future _show( home: Scaffold( body: TavernVisualNovelView( pages: pages, - name: '林晚', + name: name, avatarPath: null, topInset: 60, onMenu: (_) {}, @@ -149,6 +151,44 @@ void main() { expect(find.byKey(const ValueKey(('tavern-vn-cast', '路人'))), findsOneWidget); }); + testWidgets("a group's backlog names each line by who said it, not by the group", (tester) async { + TavernVnCast? cast(String id) => null; + await _show( + tester, + [ + _page('a', TavernRole.character, '欢迎光临。', speakerId: 'lin'), + _page('b', TavernRole.character, '今晚有雨。', speaker: '星图', speakerId: 'star'), + _page('c', TavernRole.user, '那我留下。'), + _page('d', TavernRole.narrator, '门外响起了雷声。', speaker: ''), + ], + _sprites, + castFor: cast, + name: '林晚、星图', + ); + await tester.tap(find.byKey(const ValueKey('tavern-vn-log'))); + await tester.pumpAndSettle(); + final log = tester.widget(find.byType(CompanionLog)); + expect([for (final e in log.entries) e.who], ['林晚', '星图', null, '旁白']); + Finder inLog(String text) => find.descendant(of: find.byType(CompanionLog), matching: find.text(text)); + expect(inLog('林晚、星图'), findsNothing); + expect(inLog('林晚'), findsOneWidget); + expect(inLog('星图'), findsOneWidget); + expect(inLog('旁白'), findsOneWidget); + // Each in the colour the chat gives them, not all in the one accent. + final tones = { + for (final e in log.entries) + if (e.who != null) e.who: e.tone, + }; + expect(tones.values, everyElement(isNotNull)); + expect(tones['林晚'], isNot(tones['星图'])); + + // One character: her lines are just hers, as before. + await _show(tester, [_page('e', TavernRole.character, '早。')], _sprites); + await tester.tap(find.byKey(const ValueKey('tavern-vn-log'))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(CompanionLog)).entries.single.who, isNull); + }); + testWidgets('no sprites: the card face, as before', (tester) async { await _show(tester, [_page('a', TavernRole.character, '她笑着递过来一杯茶。')], const {}); expect(find.byKey(const ValueKey('tavern-vn-face')), findsOneWidget); From 6754a3a81028074f92eacc80bc64aedd08227182 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 22:50:15 +0800 Subject: [PATCH 08/51] =?UTF-8?q?feat(tavern-engine):=20P2=20B1=20?= =?UTF-8?q?=E5=8D=A1=E8=84=9A=E6=9C=AC=E6=95=B0=E6=8D=AE=E6=A1=A5=EF=BC=88?= =?UTF-8?q?Pro=20131-r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tavern.scripts:context/snapshot/watchEvents、六个消息入口、五个变量 scope、 App host 接口和主回合生命周期屏障。按原始层合入:4af464df + checkpoint-raw125r3 + P2-b1-131-r1-on125-r3.patch(aeff6c7f…),format 后和本分支比出增量,16 个路径。 代跑 reply-131:9 文件 guard 168 过;本分支引擎全包 2581 过 / 6 跳,analyze 无诊断。 --- packages/dsh_tavern/README.md | 87 +- packages/dsh_tavern/lib/dsh_tavern.dart | 1 + packages/dsh_tavern/lib/src/api.dart | 12 +- packages/dsh_tavern/lib/src/engine.dart | 617 +++++-- packages/dsh_tavern/lib/src/engine_group.dart | 21 +- .../lib/src/engine_script_variables.dart | 1068 ++++++++++++ .../dsh_tavern/lib/src/engine_scripts.dart | 1431 ++++++++++++++++ .../lib/src/engine_scripts_messages.dart | 1447 +++++++++++++++++ .../dsh_tavern/lib/src/io/chat_jsonl.dart | 3 +- packages/dsh_tavern/lib/src/scripts.dart | 755 +++++++++ .../lib/src/store/character_library.dart | 36 + .../dsh_tavern/lib/src/store/chat_store.dart | 277 ++++ .../test/p2_script_lifecycle_test.dart | 950 +++++++++++ .../test/p2_script_messages_test.dart | 789 +++++++++ .../test/p2_script_test_support.dart | 497 ++++++ .../test/p2_script_variables_test.dart | 618 +++++++ 16 files changed, 8453 insertions(+), 156 deletions(-) create mode 100644 packages/dsh_tavern/lib/src/engine_script_variables.dart create mode 100644 packages/dsh_tavern/lib/src/engine_scripts.dart create mode 100644 packages/dsh_tavern/lib/src/engine_scripts_messages.dart create mode 100644 packages/dsh_tavern/lib/src/scripts.dart create mode 100644 packages/dsh_tavern/test/p2_script_lifecycle_test.dart create mode 100644 packages/dsh_tavern/test/p2_script_messages_test.dart create mode 100644 packages/dsh_tavern/test/p2_script_test_support.dart create mode 100644 packages/dsh_tavern/test/p2_script_variables_test.dart diff --git a/packages/dsh_tavern/README.md b/packages/dsh_tavern/README.md index f5d440bb47..c408f1922e 100644 --- a/packages/dsh_tavern/README.md +++ b/packages/dsh_tavern/README.md @@ -1074,6 +1074,91 @@ SQLite 注册迁移升至 7:group_chats、group_members、nodes.speaker_charac App 的 `DeferredTavern` 要转发所有新群方法,Preview 由 owner 按完整群事件序列模拟。本层没有读取或修改 App。`TavernChatSummary` 增加 kind 与不可变 memberCharacterIds;为复制列表,构造器不再 const,既有 `const TavernChatSummary(...)` 调用应移除 const。其 characterId 在群里表示主持,消息演员应读 nullable speakerCharacterId。新 DTO 字段均有单聊默认值;不把主持头像当成每条消息的发言人。 -本层未实现 P2 B 的脚本注册、JS 宿主或 MVU:settling/generationId 是真实提交边界和后续接合点,当前不会制造成功 hook Ack。B 接入时必须在 child SQL 已提交之后完成宿主同步/handler 写确认,再发 Done;同 hook 目标变量短写不能排队等待自己的 outer.done,外部结构变化则取消剩余。批尾 story 当前复用已提交 child 的私有缓冲;B 若允许 hook 编辑正文/变量,必须先刷新或仅提交最新运行态,不能用旧缓冲覆盖 hook 写入。本层没有声称该未来 hook 合同已实测。 +群聊切片本身不运行 JS 或 MVU。下面的 B1 累计层已把 settling/generationId 接到真实的缓存和回复 handler 屏障;群尾只提交当前节点的新 story 状态与审计,不再把旧 child 正文、reasoning 或 inspection 缓冲写回。JS 执行器和固定 MVU 构建仍由 App/owner 接入验证,不能从 native hook 完成推断 MVU 已兼容。 固定参考为 ST `7e8663cd9c184a550b37238218bdd32c6efc68e9` 的 [group-chats.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/group-chats.js)、[utils.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/utils.js)、[openai.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/openai.js) 及 [world-info.js](https://github.com/SillyTavern/SillyTavern/blob/7e8663cd9c184a550b37238218bdd32c6efc68e9/public/scripts/world-info.js)。这次只读源码,没有新增 ST probe;`p2_group_store_engine_test.dart`、`p2_group_turn_engine_test.dart` 和旧 schema 回归的实际运行由正式代跑给证据,不把静态源码审阅当作通过。 + +# P2 B1:脚本数据桥与持久回复屏障 + +`Tavern.scripts` 提供原生的 `TavernScriptBridge`。本增量实现 context/snapshot、消息与 swipe family、五个变量 scope、实际提交版本、可等待的主生命周期和 App host 接口。它不内置 JavaScript 引擎,不解析 MVU,不实现后继后台生成、注入、STscript 或 UI 命令。`capabilities.adapterVersion` 为 `p2-b1`,只列实际实现的方法;没有 host 时持久数据操作仍可使用,`hostAttached=false` 不伪造宿主成功 Ack。 + +这些调用/事件边界遵循已交付 P2 API 和项目勘误,尤其派生通知、swipe 改长及主生命周期重入是项目协议,不声称它们都已由固定 helper 原件或实际 MVU 构建证明。当前作者只做源码实施与静态自读;本层 Dart 编译、analyze、测试、App 和 MVU 的实际结论以对应正式回件为准。 + +## Context、消息身份与快照 + +```dart +final bridge = tavern.scripts; +final context = await bridge.openContext(TavernScriptContextSpec( + instanceId: 'card-frame-1', chatId: chatId, characterId: characterId, +)); +final page = await bridge.getChatMessages( + context.id, const TavernMessageRange(first: 0, last: -1), + includeSwipes: true, +); +final saved = await bridge.getVariables(context.id, + const TavernVariableTarget(scope: TavernVariableScope.chat)); +await bridge.replaceVariables(context.id, + const TavernVariableTarget(scope: TavernVariableScope.chat), + {...saved.value, 'visited': true}, expectedVersion: saved.version, +); +``` + +context 是当前 engine epoch 下的运行身份;`instanceId` 不是持久 script 键。群 context 未明确 characterId 时不猜主持或首成员。消息真实身份是 `(chatId,nodeId)`;楼层号来自完整选中 ancestor path,包含 hidden/system,先编号后筛选,不受 native `path` 默认分页限制。范围两端为闭区间,负数从路径末尾计算。数字写入在调用时固定节点,之后的异步等待不重新解释同一数字;历史 swipe 的明确 ref 可直接指定非当前页。 + +`snapshot` 是独立当前读取,不派发事件、不 Ack lifecycle、不改变已发布 delta 的比较基线,也不等待当前 cache Ack。各 scope 可经过不同文件队列,返回前会同步复验这次读到的每个目标 commit token 和路径修订;坏形状的观察也带当时 token,发生交错则重读,避免旧值配上较新 lastSeq。App 在安装 snapshot 前缓冲 cache delivery,随后按水位去重;仍待 Ack 的 lifecycle 不因 `seq <= lastSeq` 被丢弃。 + +仅打开的 context 构造脚本路径/消息缓存;每个 token checkpoint 不把整个历史发到 iframe。cache delta 含实际变更的消息、删除 ref、楼层索引变化、变量快照与资源引用,namespace/name 为空。每 context 的 seq 在实际派发时递增;稳定 eventId、generationId 与 message ref 的职责不同。 + +## 原始消息与 family 修改 + +`setChatMessages`、`createChatMessages`、`deleteChatMessages`、`rotateChatMessages`、`selectSwipe` 都在完整预检后提交一笔 SQL;失败不留半批。raw setter 不运行宏或正则,默认不发送模型请求。extra 是用户投影,内部 `extra.dshgo` 保留且不可直接覆盖。显式 hidden 写同步已有历史 system 标记;raw `role=system` 是 comment/system,hidden=false 仍不进入模型;原 narrator 若只回写相同投影,不被改成 comment。 + +读消息同时给 `hasData/rawData/dataIssue`:缺少 messageVariables 的 data 投影为 `{}`;历史 null/数组/其他非对象原样保留,data 为 null 并报告形状。`getVariables` 对后者报 shapeMismatch;明确整对象替换可以按统一状态通道修复,不能默默把坏形状当成空值。 + +swipes 留存索引保留原 nodeId,至少一页。裁掉入场目标或选中页必须显式给保留的 swipeId;被删页的孩子转挂到保留选中页,后代 ID/有效状态保留,祖先前缀可改变。新页缺省 data 克隆入场目标的完整有效状态;既有页未给 data 不改。重复给 message/data/extra 与对应 selected swipe 值时必须一致,否则整批拒绝。新增页不复制伪造模型 inspection/usage。 + +整楼删除移除该层所有可见 swipe 页,保留并重接后方分支;rotate 交换两段完整 family,不等于选页。物理 checkpoint 与 portable override 分开保存,重接前保全受影响状态;seq/FTS 身份不重新编号。返回 mutation 的 refs、indexChanges、stateEdits 和 actual upserts 来自本次真实提交。 + +## 五 scope、JSON 与版本 + +| scope | 持久目标 | +| --- | --- | +| global | settings.json 的 globalVariables,与 native 设置/宏保存共用实际文件队列 | +| character | 卡 extensions.tavern_helper.variables,保留其他卡字段、媒体和原件 | +| chat | chats.variables,并同步当前节点或群空路径 initial_state 的变量载体 | +| message | 指定稳定页的有效 state.messageVariables,独立于 chat locals | +| script | owner(kind,assetId)+scriptId 的 data;卡/预设保留原脚本树,全局为 settings.json 的 scriptData | + +卡/预设脚本定位支持公开 folder.scripts 结构,兼容已有 children 容器;同名或相同 scriptId 不跨 owner 合并。脚本数据与 iframe instance 无关。未知 JSON 字段保留,不扩成安装/执行脚本管理器。 + +输入在调用时严格深拷贝,只接受 null、String、bool、有限 num、字符串键 Map 和 List;循环、自定义对象/函数、非字符串键、NaN/Infinity 报 invalidJson,不调用任意 `toJson`。replace 完整替换对象;对象成员 null 是值,只有 remove 才删除。mutate 使用已拆路径 token;insertIfAbsent 按存在性判断,null/false/0/空串均已存在;数组删除收缩数组,不制造稀疏洞。 + +version 按具体持久目标独立计数,包含 engine epoch。settings/card 的成功 rename、外层 SQL COMMIT 与内存采用后,同步观察已登记目标;真实 A→B→A 两次提交也使旧 token 失效。回滚、失败 rename 和无实际值变化不提前推进版本;无关目标不会制造冲突。CAS 在最终实际队列内读取最新值并检查。`updateVariablesWith` 只在写队列外调用 updater 一次,返回后 CAS;冲突不重试函数。updater 可以 await engine.close,任意用户 Future 不会被当成必须排空的存储作业,关闭后迟到结果不提交。 + +全局原生宏立即改变 live RAM,但保存只记录已计算的根 set/remove 意图;同值 set、删除当前不存在的键也保留顺序。实际文件队列从最新已提交基底合入本次 cutoff 内尚未提交的意图;若 JSON 有变化,成功 rename 后才确认,无 JSON 变化可确认日志而不追加 IO。不会重跑宏或 RNG,也不会用入队前的旧整对象覆盖其他写者。失败保留原生 RAM 效果与未提交意图,磁盘/bridge version 不前移;close 只排空已接纳 IO,不自动重试失败保存。普通 settings/scriptData 保存不领取这些原生意图。 + +`setGlobalVariables`、bridge replace 和 updater 返回的整对象是明确全替换:真正接纳写入、进入文件队列之前捕获 native cutoff;成功时准确保存替换对象,覆盖 cutoff 之前的原生值,包括先前保存失败而留在 RAM 的值,并保留调用之后的 native 意图。updater 的 cutoff 在函数返回后的提交接纳时捕获。bridge path mutate 则先在队列检查已提交 CAS/形状,合入 cutoff 内 pending native 意图,再执行路径计划一次;未提到的 native 键保持。重叠操作期间,native getter 的 live 值可以短暂包含 bridge snapshot 尚未提交的后续意图;bridge 返回其实际持久提交对象,不把 pending 当作已提交版本。原生宏非有限 Number 继续留在 RAM,持久及 bridge JSON 投影为 null,未修改这些键的 path mutate 不把 RAM 强制改成 null;bridge 新输入仍严格拒绝非有限数。这些是本项目的并发提交合同,未宣称为固定 helper 实现对拍结论。 + +文件与 SQL 不承诺跨资源原子。card.json 已 rename 但派生 summary 失败时,重新读取权威卡并以 committed=true 报告;成功提交后 host/cache 失败也不能说数据没保存。调用方应根据 committed 和实际读取决定恢复,不盲目重放写入。 + +## 同一个 message data / DSH 通道 + +变量 message scope、set/data、swipesData 和 create/data 共用状态准备与提交。普通 sibling(例如 stat_data)整对象替换不另解析 MVU;dshgo 没变就不执行 rules、不中断原 runtime/秘密/事件/故事账本。已有 schema 且 dshgo 改变时走 user/source=script 的真实 StateSession,新 invocation 进行校验、clamp/derive/rules 后返回 actual、收据、问题和审计。省略已存在的 dshgo 按 remove_root 校验,拒绝时整笔/整批都不提交;不会先保存普通 sibling。无 schema 的 dshgo 仍是普通 JSON。 + +写历史页时先保全直接 physical children 的旧 checkpoint(含 retained deleted children),再写目标 state 和 portable/audit;不能用新父状态污染未修改子页。所有 DSH 文件准备在 chat gate 外,最终同步检查主持定义修订;SQL 内不等待角色文件队列。 + +## Host、完成边界与群接合 + +host 的 cache 通道必须可重入:RECEIVED handler 可以 await 变量或正文短写,而该写会先要求 App 应用真实 cache。普通 bridge 写 Future 只等待真实提交、actual 和 cache 确认;其产生的 EDITED/UPDATED/SWIPED/DELETED 与画布 refresh 独立调度,不纳入该写 Future 或 RECEIVED 的隐式传递闭包。raw create 只有 cache/refresh;纯 data 写只有 cache;正文变更 EDITED、其他显示字段 UPDATED、实际选页 SWIPED、真实移除 DELETED。 + +主回合按 STARTED → AFTER_COMMANDS → 冻结本次请求 → 模型/regex/state/SQL → cache → awaited RECEIVED 及其直接写 flush → actual Done → ENDED。AFTER_COMMANDS 是可改本次准备的屏障。SENT 保留真实用户提交的 ref/楼层/修订,但在完整 body 深拷贝及 config 捕获后才异步派发,不等其 handler;准备失败/取消也只报告一次已发生的提交。群的用户 SENT 在首位演员的同一捕获边界发出,无演员则在批末报告。SENT 异步写不修改已冻结请求,也不保证赶上后续演员准备。 + +主生命周期不排在派生通知队尾。App 可串行派生 listener,但单聊 ENDED handler 若启动并 await 下一代,必须允许新 generationId 的 STARTED/RECEIVED 重入;不能把全部生命周期锁在旧 ENDED 后。同 chat 在 RECEIVED 等待中仍 busy;群 actor ENDED 时 outer 仍占用,不能另起同群主生成。没有声称这些项目调度选择等于固定 ST listener 的全域行为。 + +STOPPED/ENDED 的 args[0] 使用实际 Done/Failed 的 status、generationType、cancelled、committed、finishReason、modelFailure、replyRef、partialRef 与完整 hookIssues;dispatch 另有 generationId/nodeRef。committed 专指成功模型结算,失败保存的 partial 由 partialRef 明示;停止发生在已提交模型的 hook 等待期时,completed 与 cancelled=true 可以同时成立,不伪造 modelFailure。impersonate 临时消息不冒充持久 ref。模型已提交后的 resume 读取当前保存的回复,保留 handler 正文编辑。 + +准备与模型提交后的数据短写不等自己的 turn.done/outer.done;模型写阶段未 drain 或结构/选路变动则先取消并排空。stop、detach、closeContext、engine.close 解除相应宿主等待,迟到 Ack 不转给新 host。已提交模型与短写不会因 hookFailure 回滚;Done/Failed 的 hookIssues 分开报告。群下一演员在 RECEIVED 明确屏障后读最新已提交数据;批尾只写新 story state/audit/cache,不覆盖 hook 已改正文,不重复 T17 结算。 + +cache、lifecycle 和 refresh 的取消/失败通过 watchEvents 可观察;watchEvents 不承担 Ack,也不重新运行 JS。close 先取消回调 race,再排空实际 IO;不等待任意无响应 JS Promise或暂停的观察者。B1 没有新增 schema:继续使用 7 与现有备份/JSONL 的真实数据载体。 + +App 的 DeferredTavern 需新增 scripts 转发或稳定代理;同步 getter 不能返回 Future,也不能返回一个假成功 bridge。Preview 由 owner 按实际能力模拟并设置 simulated=true,未实现的方法不放 capabilities。`TavernTurnDone` / `TavernTurnFailed` 新增不可变 hookIssues,构造器不再 const,既有 const 调用须移除;群消息仍透传 speakerCharacterId。这次没有读取/修改 App,没有执行固定 MVU 构建。 diff --git a/packages/dsh_tavern/lib/dsh_tavern.dart b/packages/dsh_tavern/lib/dsh_tavern.dart index b5aff02efe..1fb455457e 100644 --- a/packages/dsh_tavern/lib/dsh_tavern.dart +++ b/packages/dsh_tavern/lib/dsh_tavern.dart @@ -17,6 +17,7 @@ export 'src/macros/macro_engine.dart'; export 'src/persona.dart'; export 'src/preset_library.dart'; export 'src/regex_library.dart'; +export 'src/scripts.dart'; export 'src/state.dart'; export 'src/io/preset_codec.dart'; export 'src/prompt/character_fields.dart'; diff --git a/packages/dsh_tavern/lib/src/api.dart b/packages/dsh_tavern/lib/src/api.dart index ca24335a3c..e4cc2bacaf 100644 --- a/packages/dsh_tavern/lib/src/api.dart +++ b/packages/dsh_tavern/lib/src/api.dart @@ -16,6 +16,7 @@ import 'lorebook_library.dart'; import 'persona.dart'; import 'preset_library.dart'; import 'regex_library.dart'; +import 'scripts.dart'; import 'state.dart'; import 'worldinfo/world_info.dart' show WorldInfoSource; @@ -289,7 +290,8 @@ class TavernReasoningDelta extends TavernEvent { } class TavernTurnDone extends TavernEvent { - const TavernTurnDone(this.message, {this.finishReason}); + TavernTurnDone(this.message, {this.finishReason, List hookIssues = const []}) + : hookIssues = List.unmodifiable(hookIssues); /// As stored: write-back regex applied, usage filled in. final TavernMessage message; @@ -297,6 +299,7 @@ class TavernTurnDone extends TavernEvent { /// The provider's finish_reason, including length or content_filter. Null /// means the transport did not report one; unknown future values are kept. final String? finishReason; + final List hookIssues; } enum TavernFailure { @@ -313,7 +316,8 @@ enum TavernFailure { } class TavernTurnFailed extends TavernEvent { - const TavernTurnFailed(this.kind, {this.detail, this.partial}); + TavernTurnFailed(this.kind, {this.detail, this.partial, List hookIssues = const []}) + : hookIssues = List.unmodifiable(hookIssues); final TavernFailure kind; /// For 「详情」: a status code, the first part of an error body. @@ -322,6 +326,7 @@ class TavernTurnFailed extends TavernEvent { /// Whatever arrived before it failed or was stopped; null when nothing was /// stored (the user's line goes back into the composer). final TavernMessage? partial; + final List hookIssues; } enum TavernCardFormat { png, json, charx } @@ -375,6 +380,9 @@ class TavernModelConfig { /// The tavern. One per app; [open] it on the app's support directory. abstract interface class Tavern { + /// Persisted script data and awaited App lifecycle bridge. Capabilities + /// distinguish the implemented surface from later script/generation layers. + TavernScriptBridge get scripts; // Library ------------------------------------------------------------------ /// The saved capability snapshot, or null for a missing/legacy asset. diff --git a/packages/dsh_tavern/lib/src/engine.dart b/packages/dsh_tavern/lib/src/engine.dart index afdf21db93..5548ce3044 100644 --- a/packages/dsh_tavern/lib/src/engine.dart +++ b/packages/dsh_tavern/lib/src/engine.dart @@ -37,6 +37,7 @@ import 'prompt/prompt_types.dart'; import 'regex/regex_engine.dart'; import 'regex/regex_worker.dart'; import 'regex_library.dart'; +import 'scripts.dart'; import 'state.dart'; import 'state/state_engine.dart' as state_core; import 'story/story_engine.dart' as story_core; @@ -53,6 +54,9 @@ part 'engine_story.dart'; part 'engine_card_events.dart'; part 'engine_secrets.dart'; part 'engine_group.dart'; +part 'engine_scripts.dart'; +part 'engine_script_variables.dart'; +part 'engine_scripts_messages.dart'; /// App convenience factory. [directory] is the actual tavern data directory, /// normally `/tavern`. No extra path component is appended. @@ -131,11 +135,14 @@ class TavernEngine implements Tavern { this._regexWorker, this._regexLibrary, ) { - _globalVariables = _map(_document['globalVariables']); + _globalVariables = _NativeGlobals(_map(_document['globalVariables'])); + _store.committedObserver = _scripts.storeCommitted; + _library.cardCommitted = _scripts.cardCommitted; _storeSubscription = _store.changes.listen((chatId) { if (_closed) return; _paths.add(chatId); _directors.changed(chatId); + _scripts.nativeChanged(chatId); if (!_mainBusy(chatId)) _notifyLists(); }); _personaSubscription = _personas.changes.listen((_) => _directors.changedAll()); @@ -243,7 +250,7 @@ class TavernEngine implements Tavern { late final _worldInfoEngine = WorldInfoEngine(worker: _keywordWorker); late final _chatCodec = ChatJsonlCodec(clock: _clock); final _presetCodec = const PresetCodec(); - late final Map _globalVariables; + late final _NativeGlobals _globalVariables; late final StreamSubscription _storeSubscription; late final StreamSubscription _personaSubscription; final _charactersChanged = StreamController.broadcast(); @@ -251,6 +258,13 @@ class TavernEngine implements Tavern { final _paths = StreamController.broadcast(); final _turns = {}; late final _GroupCoordinator _groups = _GroupCoordinator(this); + late final _ScriptCoordinator _scripts = _ScriptCoordinator(this); + @override + TavernScriptBridge get scripts { + _ensureOpen(); + return _scripts; + } + bool _mainBusy(String chatId) => _turns.containsKey(chatId) || _groups.rounds.containsKey(chatId); final _mutations = >{}; final _deletingCharacters = {}; @@ -410,6 +424,7 @@ class TavernEngine implements Tavern { void _notifyLists() { if (_closed || _closing) return; + _scripts.resourcesChanged(); _charactersChanged.add(null); _chatsChanged.add(null); } @@ -845,7 +860,7 @@ class TavernEngine implements Tavern { if (_deletingPersonas.contains(selection.persona?.id)) throw StateError('Persona deletion is running.'); final settings = _personaSettings(_effectiveSettings(), selection); final variables = {}; - final beforeGlobals = _jsonString(_globalVariables); + final beforeGlobals = _globalVariables.revision; late final String id; try { id = _store.transaction(() { @@ -899,7 +914,9 @@ class TavernEngine implements Tavern { // SQL is rolled back, but preparation follows ST/T8 global-variable // semantics: preserve and save effects that happened before the error. try { - if (beforeGlobals != _jsonString(_globalVariables)) await _saveDocument(); + if (_globalVariables.changedSince(beforeGlobals)) { + await _saveDocument(); + } } catch (saveError, saveStackTrace) { Error.throwWithStackTrace( _PreparationSaveFailure(createError, createStackTrace, saveError, saveStackTrace), @@ -909,7 +926,9 @@ class TavernEngine implements Tavern { Error.throwWithStackTrace(createError, createStackTrace); } _liveVariables[id] = (leaf: _store.chat(id).currentLeafId, values: _runtimeCopy(variables)); - if (beforeGlobals != _jsonString(_globalVariables)) await _saveDocument(); + if (_globalVariables.changedSince(beforeGlobals)) { + await _saveDocument(); + } _notifyLists(); return id; }), @@ -1585,7 +1604,7 @@ class TavernEngine implements Tavern { // This legacy mixed API has no transaction spanning a file and SQL. // Keep its established in-memory/file behavior, then commit selection. _document['settings'] = settings; - await _saveDocument(); + await _saveDocument(writeGlobals: false); if (hasPreset) { _store.transaction( () => _selectLegacyPreset( @@ -1638,12 +1657,13 @@ class TavernEngine implements Tavern { await _directors.settingsChanged(chatId); } - Future setGlobalVariables(Map variables) => _operation(() async { - final copy = _copy(variables); - _globalVariables - ..clear() - ..addAll(copy); - await _saveDocument(); + Future setGlobalVariables(Map variables) => _operation(() { + final captured = _copy(variables); + final through = _globalVariables.revision; + return _settingsDocument((document) async { + final next = _copy(document)..['globalVariables'] = captured; + await _commitSettingsDocument(next, globals: (runtime: captured, through: through)); + }); }); /// Reads the most recent real request. This has no variable/random effects. @@ -2411,9 +2431,59 @@ class TavernEngine implements Tavern { return value; } - Future _saveDocument() { - _document['globalVariables'] = _copy(_globalVariables); - return _writeJson(File('${directory.path}/settings.json'), _document); + Future _saveDocument({bool writeGlobals = true}) { + final through = _globalVariables.revision; + return _settingsDocument((document) async { + if (!writeGlobals) { + await _commitSettingsDocument(document); + return; + } + // Claim only still-uncommitted native intent at this real queue position. + // An earlier flush may already have saved the same prefix. + if (!_globalVariables.hasThrough(through)) { + return; + } + final runtime = _globalVariables.project(through); + final persisted = _copy(runtime); + if (_jsonString({'value': document['globalVariables']}) == _jsonString({'value': persisted})) { + _adoptGlobals(runtime, through); + return; + } + document['globalVariables'] = persisted; + await _commitSettingsDocument(document, globals: (runtime: runtime, through: through)); + }); + } + + void _adoptGlobals(Map runtime, int through) { + _document['globalVariables'] = _copy(runtime); + _globalVariables.commit(runtime, through); + } + + // Every writer of settings.json encodes while owning the same file queue. + // A native settings-only save must not carry globals captured before an + // earlier bridge variable write has committed. + Future _settingsDocument(Future Function(Map document) action) { + final result = _fileQueue.then((_) => action(_copy(_document))); + _fileQueue = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + Future _commitSettingsDocument( + Map document, { + ({Map runtime, int through})? globals, + }) async { + final file = File('${directory.path}/settings.json'); + await file.parent.create(recursive: true); + final temporary = File('${file.path}.writing'); + await temporary.writeAsString(jsonEncode(document), flush: true); + await temporary.rename(file.path); + if (globals != null) { + _adoptGlobals(globals.runtime, globals.through); + } + _scripts.documentCommitted(document); + if (globals != null) { + _scripts.resourcesChanged(); + } } Future _writeJson(File file, Map json) { @@ -2439,6 +2509,7 @@ class TavernEngine implements Tavern { // this same gate; none can enter while another affected chat is draining. for (final id in ids) { _mutations[id] = released.future; + _scripts.cancelChat(id); _groups.cancel(id); _turns[id]?.cancellation.cancel(); } @@ -2471,15 +2542,32 @@ class TavernEngine implements Tavern { } @override - Future selectSibling(String chatId, String nodeId, int index) => _operation( - () => _withMutation(chatId, () { + Future selectSibling(String chatId, String nodeId, int index) => _operation(() async { + String? previous; + late String selected; + await _withMutation(chatId, () { _store.transaction(() { + final parent = _store.node(chatId, nodeId).message.parentId; + previous = parent == null ? _store.chat(chatId).selectedRootId : _store.node(chatId, parent).selectedChildId; _store.selectSibling(chatId, nodeId, index); + selected = _store.children(chatId, parent)[index].message.nodeId; _restoreVariables(chatId); }); _liveVariables.remove(chatId); - }), - ); + }); + await _scripts.nativeMutation( + chatId, + 'MESSAGE_SWIPED', + nodeId: selected, + args: [ + { + 'oldRef': previous == null ? null : {'chatId': chatId, 'nodeId': previous}, + 'newRef': {'chatId': chatId, 'nodeId': selected}, + 'swipeId': index, + }, + ], + ); + }); @override Future selectNode(String chatId, String nodeId, {bool restoreDescendants = false}) => _operation( @@ -2563,7 +2651,7 @@ class TavernEngine implements Tavern { groupNames: groupView?.names, groupNotMuted: groupView?.enabledNames, ); - final globals = _runtimeCopy(_globalVariables); + final globals = _globalVariables.checkpoint(); try { final placement = switch (target.message.role) { TavernRole.user => RegexPlacement.userInput, @@ -2596,14 +2684,14 @@ class TavernEngine implements Tavern { } }); } catch (_) { - _globalVariables - ..clear() - ..addAll(globals); + _globalVariables.rollback(globals); rethrow; } if (current) _liveVariables[chatId] = (leaf: leaf, values: _runtimeCopy(local)); - if (_jsonString(globals) != _jsonString(_globalVariables)) await _saveDocument(); - }), + if (_globalVariables.changedSince(globals.revision)) { + await _saveDocument(); + } + }).then((_) => _scripts.nativeMutation(chatId, 'MESSAGE_EDITED', nodeId: nodeId)), ); void _writeNodeState(String chatId, String nodeId, Map state) { @@ -2640,35 +2728,47 @@ class TavernEngine implements Tavern { @override Future deleteMessage(String chatId, String nodeId) => _operation( - () => _withMutation(chatId, () { - final stored = _storedLatestInspection(_store.chat(chatId)); - final latest = _latestInspections.containsKey(chatId) ? _latestInspections[chatId] : stored; - var clearLatest = false; - _store.transaction(() { - final chat = _store.chat(chatId); - _store.deleteMessage( - chatId, - nodeId, - capturePortableState: (child, physicalState) { - final override = _map(child.extra['dshgo'])['tavernState']; - final state = _copy(override is Map ? override : physicalState); - // Resolve the child's own old source before any edge or current - // metadata changes. This callback never reads a hidden node via a - // visibility-filtered API, and never writes or evaluates macros. - return _withStateHash(chat, state); - }, - ); - _restoreVariables(chatId); - clearLatest = latest != null && !_inspectionAnchorExists(latest); - if (clearLatest || (stored != null && !_inspectionAnchorExists(stored))) { - _writeLatestInspection(chatId, null); - } - }); - if (clearLatest) { - _latestInspections[chatId] = null; - } - _liveVariables.remove(chatId); - }), + () => + _withMutation(chatId, () { + final stored = _storedLatestInspection(_store.chat(chatId)); + final latest = _latestInspections.containsKey(chatId) ? _latestInspections[chatId] : stored; + var clearLatest = false; + _store.transaction(() { + final chat = _store.chat(chatId); + _store.deleteMessage( + chatId, + nodeId, + capturePortableState: (child, physicalState) { + final override = _map(child.extra['dshgo'])['tavernState']; + final state = _copy(override is Map ? override : physicalState); + // Resolve the child's own old source before any edge or current + // metadata changes. This callback never reads a hidden node via a + // visibility-filtered API, and never writes or evaluates macros. + return _withStateHash(chat, state); + }, + ); + _restoreVariables(chatId); + clearLatest = latest != null && !_inspectionAnchorExists(latest); + if (clearLatest || (stored != null && !_inspectionAnchorExists(stored))) { + _writeLatestInspection(chatId, null); + } + }); + if (clearLatest) { + _latestInspections[chatId] = null; + } + _liveVariables.remove(chatId); + }).then( + (_) => _scripts.nativeMutation( + chatId, + 'MESSAGE_DELETED', + nodeId: nodeId, + args: [ + { + 'ref': {'chatId': chatId, 'nodeId': nodeId}, + }, + ], + ), + ), ); @override @@ -2727,7 +2827,7 @@ class TavernEngine implements Tavern { _store.updateMessage(chatId, nodeId, original: {...node.original, 'is_system': hidden}); _store.setHidden(chatId, nodeId, hidden); }); - }), + }).then((_) => _scripts.nativeMutation(chatId, 'MESSAGE_UPDATED', nodeId: nodeId)), ); void _restoreVariables(String chatId) { @@ -2768,8 +2868,19 @@ class TavernEngine implements Tavern { Future narrate(String chatId, String text, {String? speaker}) => _operation(() => _appendManualMessage(chatId, text, narrator: true, speaker: speaker)); - Future _appendManualMessage(String chatId, String text, {required bool narrator, String? speaker}) => - _withMutation(chatId, () => _appendMessage(chatId, text, narrator: narrator, speaker: speaker)); + Future _appendManualMessage( + String chatId, + String text, { + required bool narrator, + String? speaker, + }) async { + final message = await _withMutation( + chatId, + () => _appendMessage(chatId, text, narrator: narrator, speaker: speaker), + ); + await _scripts.nativeMutation(chatId, 'MESSAGE_SENT', nodeId: message.nodeId); + return message; + } Future _appendMessage( String chatId, @@ -2837,7 +2948,7 @@ class TavernEngine implements Tavern { groupNames: groupView?.names, groupNotMuted: groupView?.enabledNames, ); - final beforeGlobals = _jsonString(_globalVariables); + final beforeGlobals = _globalVariables.revision; late final String expanded; try { // Adapted from SillyTavern (AGPL-3.0), commit @@ -2854,7 +2965,7 @@ class TavernEngine implements Tavern { // Local state is detached, but preparation uses ST/T11's real globals. // Preserve both failures when those effects cannot be saved either. try { - if (beforeGlobals != _jsonString(_globalVariables)) { + if (_globalVariables.changedSince(beforeGlobals)) { await _saveDocument(); } } catch (saveError, saveStackTrace) { @@ -2874,7 +2985,7 @@ class TavernEngine implements Tavern { // Save preparation effects before SQL: a failed write must not append a // manual message. Later SQL failure rolls back local state and the tree, // while already saved global preparation effects remain committed. - if (beforeGlobals != _jsonString(_globalVariables)) { + if (_globalVariables.changedSince(beforeGlobals)) { await _saveDocument(); } final nextState = { @@ -2932,9 +3043,7 @@ class TavernEngine implements Tavern { _ensureOpen(); _store.chat(chatId); if (_groups.rounds.containsKey(chatId)) { - controller.add( - const TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_requires_resumeGroupTurn'), - ); + controller.add(TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_requires_resumeGroupTurn')); unawaited(controller.close()); return; } @@ -2969,6 +3078,7 @@ class TavernEngine implements Tavern { @override void stop(String chatId) { + _scripts.cancelChat(chatId); _groups.cancel(chatId); _turns[chatId]?.cancellation.cancel(); } @@ -2981,13 +3091,13 @@ class TavernEngine implements Tavern { try { _ensureOpen(); if (_capturingBackup) { - controller.add(const TavernTurnFailed(TavernFailure.busy, detail: 'backup_capture_in_progress')); + controller.add(TavernTurnFailed(TavernFailure.busy, detail: 'backup_capture_in_progress')); unawaited(controller.close()); return; } final chat = _store.chat(chatId); if (_mainBusy(chatId) || _mutations.containsKey(chatId)) { - controller.add(const TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); + controller.add(TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); unawaited(controller.close()); return; } @@ -3008,9 +3118,7 @@ class TavernEngine implements Tavern { final targetId = nodeId == null ? null : _store.node(chatId, nodeId).message.speakerCharacterId; final member = group.members.where((item) => item.characterId == targetId).firstOrNull; if (member == null) { - controller.add( - const TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_actor_not_in_roster'), - ); + controller.add(TavernTurnFailed(TavernFailure.invalidParameters, detail: 'group_actor_not_in_roster')); unawaited(controller.close()); return; } @@ -3027,7 +3135,7 @@ class TavernEngine implements Tavern { _deletingCharacters.contains(actorId) || group?.members.any((member) => _deletingCharacters.contains(member.characterId)) == true || _deletingPersonas.contains(selection.persona?.id)) { - controller.add(const TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); + controller.add(TavernTurnFailed(TavernFailure.busy, detail: 'chat_busy')); unawaited(controller.close()); return; } @@ -3076,10 +3184,25 @@ class TavernEngine implements Tavern { Future _run(_Turn turn) async { final chatId = turn.chat.id; - var beforeGlobals = ''; + var beforeGlobals = _globalVariables.revision; try { - beforeGlobals = _jsonString(_globalVariables); + await _scripts.beforeTurn(turn); + turn.cancellation.check(); + // Preparation handlers may write locals or message variables. Capture + // their committed state after their direct writes have drained. An + // explicit regenerate/continue target still owns its source branch, + // even while a different branch is selected in the chat. + turn.chat = _store.chat(chatId); + turn.metadata = _chatCodec.metadataForNode(_store, chatId, nodeId: turn.nodeId); + final savedSource = + _stateAt(chatId, turn.nodeId ?? turn.chat.currentLeafId)['chatJsonlSourceFile'] ?? + _map(_map(turn.chat.metadata['dshgo'])['chatJsonl'])['rootFile']; + turn.sourceFile = savedSource is String ? savedSource : null; + beforeGlobals = _globalVariables.revision; turn.originalVariables = _runtimeCopy(_liveVariables[chatId]?.values ?? turn.chat.variables); + // Only native preparation owns this rollback baseline. A cancelled + // prepare handler may already have committed independent bridge writes. + turn.nativePreparationStarted = true; turn.secretCardRevision ??= _directors._cardRevisions[turn.chat.characterId] ?? 0; // Cancellation releases the turn, not its actual local file work. // Keep the original read owned until every character file has drained. @@ -3201,6 +3324,7 @@ class TavernEngine implements Tavern { parent = message.nodeId; _liveVariables[chatId] = (leaf: parent, values: _runtimeCopy(local)); history.add(_store.node(chatId, message.nodeId)); + turn.sent = _scripts.captureSent(chatId, message.nodeId); } final prepared = prepareCharacterFields(data, persona: environment.persona, expand: environment.baseExpand); // ST expands chat[0].mes after eager card fields, before WI and before @@ -3471,11 +3595,19 @@ class TavernEngine implements Tavern { if (turn.kind != _TurnKind.impersonate) { _liveVariables[chatId] = (leaf: reply.nodeId, values: _runtimeCopy(local)); } + final requestBody = _copy(assembly.body); turn.emit(TavernTurnStarted(_buffered(turn))); - await _consume(turn, _copy(assembly.body)); + _scripts.publishSent( + turn.sent ?? turn.groupRound?.sent, + generationId: turn.generationId, + cancellation: turn.cancellation, + ); + await _consume(turn, requestBody); turn.cancellation.check(); _finishRegex(turn); - if (beforeGlobals != _jsonString(_globalVariables)) await _saveDocument(); + if (_globalVariables.changedSince(beforeGlobals)) { + await _saveDocument(); + } turn.cancellation.check(); final stateSettlement = _settleStateTurn(this, turn, successful: true); turn.stateAttempt = stateSettlement; @@ -3489,83 +3621,110 @@ class TavernEngine implements Tavern { stateSettlement: stateSettlement, storySettlement: storySettlement, ); + turn.modelCommitted = true; + if (!turn.modelDrained.isCompleted) { + turn.modelDrained.complete(); + } _groups.actorCommitted(turn); - turn.emit(TavernTurnDone(_latest(turn), finishReason: turn.finishReason)); + await _scripts.afterMessage(turn); + turn.emit(TavernTurnDone(turn.lastCommitted!, finishReason: turn.finishReason, hookIssues: turn.hookIssues)); turn.terminal = true; - if (turn.groupRound == null) _directors.afterTurn(turn); - } catch (error) { - var failure = error is TavernModelException - ? error.kind - : error is PromptBudgetExceeded - ? TavernFailure.contextTooLong - : error is TavernImportError || error is FormatException - ? TavernFailure.invalidCard - : TavernFailure.server; - var detail = error is TavernModelException ? error.detail : _safeDetail(error); - TavernMessage? partial; - var inspection = turn.inspection.copyWith( - status: TavernInspectionStatus.failed, - finishedAt: _clock(), - failure: failure, - detail: detail, - ); - try { - _finishRegex(turn); - } catch (writeError) { - failure = TavernFailure.server; - detail = 'writeback_failed (${writeError.runtimeType})'; - inspection = inspection.copyWith(failure: failure, detail: detail); - } - // Resolve the asynchronous file result before publishing one SQL - // terminal snapshot. A file failure must not prevent an empty rollback. - try { - if (beforeGlobals != _jsonString(_globalVariables)) await _saveDocument(); - } catch (fileError) { - failure = TavernFailure.server; - detail = 'turn_storage_failure (${fileError.runtimeType})'; - inspection = inspection.copyWith(failure: failure, detail: detail); + if (turn.groupRound == null && !turn.cancellation.isCancelled) { + _directors.afterTurn(turn); } - try { - final stateSettlement = _failedStateSettlement(this, turn, inspection); - final storySettlement = _failedStorySettlement(turn, inspection); - if (stateSettlement != null) inspection = inspection.copyWith(state: stateSettlement.trace); - if (turn.hasOutput) { - _commitTurn(turn, inspection, stateSettlement: stateSettlement, storySettlement: storySettlement); - partial = _latest(turn); - } else { - inspection = inspection.copyWith(clearNodeId: true); - _rollbackEmpty(turn, inspection, stateSettlement: stateSettlement, storySettlement: storySettlement); - if (turn.kind == _TurnKind.continueReply) partial = turn.originalTarget?.message; - } - // Terminal storage has added the state/story/event failure traces. - // Keep that published snapshot instead of replacing it below with - // the pre-storage inspection captured by this catch block. - inspection = turn.inspection; - } catch (storageError) { - // Do not return partial=null after a failed rollback: that would invite - // the UI to reinsert a user line that might still be in the database. - failure = TavernFailure.server; - detail = 'turn_storage_failure (${storageError.runtimeType})'; - partial = turn.lastCommitted; - if (partial == null && turn.newUserId != null) { - try { - partial = _store.message(chatId, turn.newUserId!); - } catch (_) { - /* Original failure is retained. */ - } - } - inspection = inspection.copyWith( + } catch (error) { + if (turn.modelCommitted) { + // A hook or late anchor problem cannot re-enter model settlement or + // roll back a body that has already been committed. + turn.hookIssues.add( + TavernScriptIssue( + code: 'hookFailure', + message: 'Post-commit processing failed (${error.runtimeType}).', + committed: true, + generationId: turn.generationId, + ), + ); + turn.emit(TavernTurnDone(turn.lastCommitted!, finishReason: turn.finishReason, hookIssues: turn.hookIssues)); + turn.terminal = true; + } else { + var failure = error is TavernModelException + ? error.kind + : error is PromptBudgetExceeded + ? TavernFailure.contextTooLong + : error is TavernImportError || error is FormatException + ? TavernFailure.invalidCard + : TavernFailure.server; + var detail = error is TavernModelException ? error.detail : _safeDetail(error); + TavernMessage? partial; + var inspection = turn.inspection.copyWith( status: TavernInspectionStatus.failed, finishedAt: _clock(), failure: failure, detail: detail, ); + try { + _finishRegex(turn); + } catch (writeError) { + failure = TavernFailure.server; + detail = 'writeback_failed (${writeError.runtimeType})'; + inspection = inspection.copyWith(failure: failure, detail: detail); + } + // Resolve the asynchronous file result before publishing one SQL + // terminal snapshot. A file failure must not prevent an empty rollback. + try { + if (_globalVariables.changedSince(beforeGlobals)) { + await _saveDocument(); + } + } catch (fileError) { + failure = TavernFailure.server; + detail = 'turn_storage_failure (${fileError.runtimeType})'; + inspection = inspection.copyWith(failure: failure, detail: detail); + } + try { + final stateSettlement = turn.nativePreparationStarted ? _failedStateSettlement(this, turn, inspection) : null; + final storySettlement = turn.nativePreparationStarted ? _failedStorySettlement(turn, inspection) : null; + if (stateSettlement != null) inspection = inspection.copyWith(state: stateSettlement.trace); + if (turn.hasOutput) { + _commitTurn(turn, inspection, stateSettlement: stateSettlement, storySettlement: storySettlement); + partial = _latest(turn); + } else { + inspection = inspection.copyWith(clearNodeId: true); + _rollbackEmpty(turn, inspection, stateSettlement: stateSettlement, storySettlement: storySettlement); + if (turn.kind == _TurnKind.continueReply) partial = turn.originalTarget?.message; + } + // Terminal storage has added the state/story/event failure traces. + // Keep that published snapshot instead of replacing it below with + // the pre-storage inspection captured by this catch block. + inspection = turn.inspection; + } catch (storageError) { + // Do not return partial=null after a failed rollback: that would invite + // the UI to reinsert a user line that might still be in the database. + failure = TavernFailure.server; + detail = 'turn_storage_failure (${storageError.runtimeType})'; + partial = turn.lastCommitted; + if (partial == null && turn.newUserId != null) { + try { + partial = _store.message(chatId, turn.newUserId!); + } catch (_) { + /* Original failure is retained. */ + } + } + inspection = inspection.copyWith( + status: TavernInspectionStatus.failed, + finishedAt: _clock(), + failure: failure, + detail: detail, + ); + } + _setInspection(turn, inspection); + turn.emit(TavernTurnFailed(failure, detail: detail, partial: partial, hookIssues: turn.hookIssues)); + turn.terminal = true; } - _setInspection(turn, inspection); - turn.emit(TavernTurnFailed(failure, detail: detail, partial: partial)); - turn.terminal = true; } finally { turn.flushTimer?.cancel(); + if (!turn.modelDrained.isCompleted) { + turn.modelDrained.complete(); + } turn.finished = true; if (identical(_turns[chatId], turn)) _turns.remove(chatId); if (!turn.done.isCompleted) turn.done.complete(); @@ -3575,6 +3734,12 @@ class TavernEngine implements Tavern { unawaited(observer.close()); } turn.observers.clear(); + _scripts.publishSent( + turn.sent ?? turn.groupRound?.sent, + generationId: turn.generationId, + cancellation: turn.cancellation, + ); + _scripts.ended(turn); _notifyLists(); } } @@ -3730,7 +3895,7 @@ class TavernEngine implements Tavern { // before evaluating user scripts, including scripts that later throw. turn.writeBackAttempted = true; final local = _runtimeCopy(environment.local); - final global = _runtimeCopy(environment.global); + final global = _globalVariables.checkpoint(); final history = List.of(environment.history); final hash = environment.chatIdHash; final raw = _buffered(turn); @@ -3764,9 +3929,7 @@ class TavernEngine implements Tavern { environment.local ..clear() ..addAll(local); - environment.global - ..clear() - ..addAll(global); + _globalVariables.rollback(global); environment.history ..clear() ..addAll(history); @@ -3857,6 +4020,11 @@ class TavernEngine implements Tavern { TavernMessage _buffered(_Turn turn, {bool includeUsage = true}) { final reply = turn.reply!; + if (turn.modelCommitted && includeUsage && turn.kind != _TurnKind.impersonate) { + // A logical turn can still await RECEIVED after model SQL has drained. + // Resume must show a handler's committed edit, not the old model buffer. + return _store.message(turn.chat.id, reply.nodeId); + } final reasoning = turn.reasoning!.toString(); return TavernMessage( nodeId: reply.nodeId, @@ -3875,6 +4043,44 @@ class TavernEngine implements Tavern { ); } + /// The batch tail follows script writes. It owns only this new story + /// settlement, never the prior actor's body/usage or stale inspection buffer. + void _commitGroupStory(_Turn turn, _StorySettlement settlement) { + final nodeId = turn.reply!.nodeId; + final chat = _store.chat(turn.chat.id); + if (chat.currentLeafId != nodeId || turn.groupRound!.cancellation.isCancelled) { + throw const TavernModelException(TavernFailure.cancelled, 'group_anchor_changed'); + } + final currentInspection = _nodeInspection(chat.id, nodeId); + final inspection = currentInspection?.copyWith( + director: {...currentInspection.director, 'story': settlement.trace}, + ); + _store.transaction(() { + final extra = _store.rawNodeExtra(chat.id, nodeId); + final own = _map(extra['dshgo']); + extra['dshgo'] = { + ...own, + ...?settlement.ownAudit, + 'tavernState': _copy(settlement.state), + 'inspection': ?inspection?.toJson(), + }; + turn.lastCommitted = _store.updateMessage(chat.id, nodeId, extra: extra); + _store.updateChat( + chat.id, + variables: _map(settlement.state['variables']), + metadata: _runtimeMetadata(_store.chat(chat.id).metadata, settlement.state), + ); + _writeStoryRun(_store, turn, settlement, nodeId); + }); + turn.storyRunWritten = true; + turn.state = _copy(settlement.state); + turn.variables = _runtimeCopy(_map(turn.state['variables'])); + _liveVariables[chat.id] = (leaf: nodeId, values: _runtimeCopy(turn.variables)); + if (inspection != null) { + _setInspection(turn, inspection); + } + } + TavernMessage _latest(_Turn turn) => turn.kind == _TurnKind.impersonate ? _buffered(turn) : _store.message(turn.chat.id, turn.reply!.nodeId); @@ -3884,6 +4090,14 @@ class TavernEngine implements Tavern { _StateSettlement? stateSettlement, _StorySettlement? storySettlement, }) { + if (!turn.nativePreparationStarted) { + // No native message, selection, local state or runtime was prepared. + // Preserve successful handler writes and merge only this failure record + // into the current stored metadata. + _store.transaction(() => _writeLatestInspection(turn.chat.id, failed)); + _setInspection(turn, failed); + return; + } if (stateSettlement != null) failed = failed.copyWith(state: stateSettlement.trace); final events = stateSettlement?.events; if (events != null) { @@ -4085,6 +4299,7 @@ class TavernEngine implements Tavern { Future _close() async { _closing = true; + _scripts.close(); for (final group in _groups.rounds.values) { group.cancel(); } @@ -4180,6 +4395,103 @@ class _PreparationSaveFailure implements Exception { 'Saving global variables also failed: $saveError'; } +class _GlobalWrite { + const _GlobalWrite(this.revision, this.key, this.present, this.value); + final int revision; + final String key; + final bool present; + final Object? value; +} + +/// Native macros share a live Map, while persistence records their already +/// computed root set/remove intent. File commits adopt only their own prefix; +/// writes made while an earlier file operation awaited I/O remain visible. +class _NativeGlobals extends MapBase { + _NativeGlobals(Map value) : _base = _runtimeCopy(value), _values = _runtimeCopy(value); + + Map _base; + final Map _values; + final List<_GlobalWrite> _pending = []; + int revision = 0; + + @override + Object? operator [](Object? key) => _values[key]; + + @override + void operator []=(String key, Object? value) { + // Same-value assignments are still intent: an earlier queued replace may + // change this key before this native operation reaches its own save. + _pending.add(_GlobalWrite(++revision, key, true, _runtimeValue(value))); + _values[key] = _runtimeValue(value); + } + + @override + Object? remove(Object? key) { + if (key is! String) { + return null; + } + // An absent key must likewise retain its explicit delete intent. + _pending.add(_GlobalWrite(++revision, key, false, null)); + return _values.remove(key); + } + + @override + void clear() { + for (final key in _values.keys.toList()) { + remove(key); + } + } + + @override + bool containsKey(Object? key) => _values.containsKey(key); + + @override + Iterable get keys => _values.keys; + + bool changedSince(int before) => _pending.any((write) => write.revision > before); + bool hasThrough(int through) => _pending.any((write) => write.revision <= through); + + Map project(int through) { + final result = _runtimeCopy(_base); + for (final write in _pending) { + if (write.revision <= through) { + _apply(result, write); + } + } + return result; + } + + void commit(Map runtime, int through) { + _base = _runtimeCopy(runtime); + _pending.removeWhere((write) => write.revision <= through); + _values + ..clear() + ..addAll(_runtimeCopy(_base)); + for (final write in _pending) { + _apply(_values, write); + } + } + + ({int revision, Map values}) checkpoint() => (revision: revision, values: _runtimeCopy(_values)); + + // Only the caller's synchronous edit/output-regex segment uses this. It + // cannot span an await or undo a successful file commit; serials never reset. + void rollback(({int revision, Map values}) checkpoint) { + _pending.removeWhere((write) => write.revision > checkpoint.revision); + _values + ..clear() + ..addAll(_runtimeCopy(checkpoint.values)); + } + + static void _apply(Map values, _GlobalWrite write) { + if (write.present) { + values[write.key] = _runtimeValue(write.value); + } else { + values.remove(write.key); + } + } +} + enum _TurnKind { send, regenerate, continueReply, impersonate } class _Turn { @@ -4209,7 +4521,7 @@ class _Turn { ); } late TavernTurnInspection inspection; - final StoredChat chat; + StoredChat chat; final _TurnKind kind; final String input; final String? nodeId; @@ -4218,21 +4530,29 @@ class _Turn { final Map settings; final DateTime at; final PersonaSelection personaSelection; - final Map metadata; - final String? sourceFile; + Map metadata; + String? sourceFile; final String? presetId; final String actorId; _GroupView? groupView; final _GroupRound? groupRound; void Function(TavernEvent)? onEvent; final cancellation = ModelCancellation(); + final generationId = 'generation-${_newStateId()}'; + final modelDrained = Completer(); + bool modelCommitted = false; + bool scriptWritesAllowed = false; + bool nativePreparationStarted = false; + final hookIssues = []; final done = Completer(); bool finished = false; bool terminal = false; + TavernEvent? terminalEvent; bool hasOutput = false; bool receivedText = false; bool dirty = false; String? newUserId; + _ScriptSent? sent; StoredNode? originalTarget; StoredNode? originalFirstMessage; String? expandedFirstMessage; @@ -4310,6 +4630,9 @@ class _Turn { void emit(TavernEvent event) { if (terminal) return; + if (event is TavernTurnDone || event is TavernTurnFailed) { + terminalEvent = event; + } onEvent?.call(event); for (final observer in observers) { if (!observer.isClosed) observer.add(event); @@ -4904,7 +5227,7 @@ PromptHistoryMessage _promptHistory(StoredNode node) => PromptHistoryMessage( content: node.message.text, name: node.message.speaker, hidden: node.message.hidden, - isSystem: node.original['is_system'] == true, + isSystem: _systemMessage(node), forceAvatar: _jsTruthy(node.original['force_avatar']), toolCalls: node.original['tool_calls'] is List ? (node.original['tool_calls'] as List).whereType>().map(_copy).toList() diff --git a/packages/dsh_tavern/lib/src/engine_group.dart b/packages/dsh_tavern/lib/src/engine_group.dart index 92c7a61fb7..12a31d5ff2 100644 --- a/packages/dsh_tavern/lib/src/engine_group.dart +++ b/packages/dsh_tavern/lib/src/engine_group.dart @@ -152,7 +152,7 @@ class _GroupCoordinator { } final settings = _personaSettings(engine._effectiveSettings(), selection); final variables = {}; - final beforeGlobals = _jsonString(engine._globalVariables); + final beforeGlobals = engine._globalVariables.revision; late final String id; try { id = engine._store.transaction(() { @@ -247,7 +247,9 @@ class _GroupCoordinator { }); } catch (createError, createStackTrace) { try { - if (beforeGlobals != _jsonString(engine._globalVariables)) await engine._saveDocument(); + if (engine._globalVariables.changedSince(beforeGlobals)) { + await engine._saveDocument(); + } } catch (saveError, saveStackTrace) { Error.throwWithStackTrace( _PreparationSaveFailure( @@ -263,7 +265,9 @@ class _GroupCoordinator { Error.throwWithStackTrace(createError, createStackTrace); } engine._liveVariables[id] = (leaf: engine._store.chat(id).currentLeafId, values: _runtimeCopy(variables)); - if (beforeGlobals != _jsonString(engine._globalVariables)) await engine._saveDocument(); + if (engine._globalVariables.changedSince(beforeGlobals)) { + await engine._saveDocument(); + } engine._notifyLists(); return id; } @@ -512,6 +516,7 @@ class _GroupCoordinator { cancellation: round.cancellation, ); round.userNodeId = message.nodeId; + round.sent = engine._scripts.captureSent(round.group.chatId, message.nodeId); } round.cancellation.check(); round.phase = TavernGroupPhase.ready; @@ -522,6 +527,7 @@ class _GroupCoordinator { } for (final actorId in round.planned) { round.cancellation.check(); + await engine._scripts.drainWrites(round.group.chatId, round.cancellation); final chat = engine._store.chat(round.group.chatId); final metadata = engine._chatCodec.metadataForNode(engine._store, chat.id); final source = @@ -551,7 +557,7 @@ class _GroupCoordinator { child.secretCardRevision = round.hostRevision; round.child = child; round.current = actorId; - round.generationId = 'generation-${_newStateId()}'; + round.generationId = child.generationId; round.remaining.removeAt(0); round.phase = TavernGroupPhase.speaking; round.failed = null; @@ -583,13 +589,16 @@ class _GroupCoordinator { round.cancellation.check(); } if (last != null) { + await engine._scripts.drainWrites(round.group.chatId, round.cancellation); // Only a complete batch advances the chapter and automatic helper tick. last.state = engine._stateAt(last.chat.id, last.reply!.nodeId); last.variables = _runtimeCopy(_map(last.state['variables'])); final story = _settleStoryTurn(engine, last, last.currentState); last.storyAttempt = story; - if (story != null) engine._commitTurn(last, last.inspection, storySettlement: story); + if (story != null) engine._commitGroupStory(last, story); engine._directors.afterTurn(last); + await engine._scripts.groupSettled(round.group.chatId, round.cancellation); + round.cancellation.check(); } _finish(round, TavernGroupEndReason.completed); } catch (error) { @@ -613,6 +622,7 @@ class _GroupCoordinator { unawaited(observer.close()); } round.observers.clear(); + engine._scripts.publishSent(round.sent, cancellation: round.cancellation); engine._notifyLists(); } } @@ -893,6 +903,7 @@ class _GroupRound { String? current; String? generationId; String? userNodeId; + _ScriptSent? sent; bool finished = false; TavernGroupProgress get progress => TavernGroupProgress( chatId: group.chatId, diff --git a/packages/dsh_tavern/lib/src/engine_script_variables.dart b/packages/dsh_tavern/lib/src/engine_script_variables.dart new file mode 100644 index 0000000000..9a812eaae9 --- /dev/null +++ b/packages/dsh_tavern/lib/src/engine_script_variables.dart @@ -0,0 +1,1068 @@ +part of 'engine.dart'; + +class _ResolvedScriptVariable { + _ResolvedScriptVariable(this.context, this.target, {this.capture, this.requireSamePath = false}); + final _ScriptContextState context; + final TavernVariableTarget target; + final _ScriptChatCapture? capture; + final bool requireSamePath; +} + +class _ScriptRawVariable { + const _ScriptRawVariable(this.present, this.value); + final bool present; + final Object? value; +} + +class _ScriptVariableVersion { + _ScriptVariableVersion(this.target, this.identity, this.fingerprint); + final TavernVariableTarget target; + final int identity; + String fingerprint; + int revision = 0; + int? messageTreeRevision; +} + +class _ScriptDataContext { + _ScriptDataContext(this.chatId, this.hostId, this.hostRevision, this.hostData, this.definition, this.settings); + final String chatId; + final String hostId; + final int hostRevision; + final Map hostData; + final state_core.StateDefinition definition; + final Map settings; +} + +class _ScriptPreparedData { + _ScriptPreparedData(this.state, this.stateEdit, this.audit, this.run); + final Map state; + final TavernStateEditResult? stateEdit; + final Map? audit; + final Map? run; +} + +extension _ScriptVariableOperations on _ScriptCoordinator { + _ResolvedScriptVariable _resolveTarget(_ScriptContextState context, TavernVariableTarget target) { + _context(context.value.id); + void invalid(String text) => + throw _scriptError(TavernScriptErrorCode.invalidArgument, 'variables', text, contextId: context.value.id); + if (target.scope != TavernVariableScope.message && (target.messageId != null || target.messageRef != null)) { + invalid('Only the message scope accepts a message identity.'); + } + if (target.scope != TavernVariableScope.character && target.characterId != null) { + invalid('Unexpected character identity.'); + } + if (target.scope != TavernVariableScope.script && target.scriptOwner != null) { + invalid('Unexpected script owner.'); + } + if (target.scope != TavernVariableScope.chat && + target.scope != TavernVariableScope.message && + target.chatId != null) { + invalid('Unexpected chat identity.'); + } + TavernVariableTarget resolved; + _ScriptChatCapture? capture; + switch (target.scope) { + case TavernVariableScope.global: + resolved = const TavernVariableTarget(scope: TavernVariableScope.global); + case TavernVariableScope.character: + final id = target.characterId ?? context.value.characterId; + if (id == null) { + throw _scriptError(TavernScriptErrorCode.noContext, 'variables', 'No character is bound.'); + } + resolved = TavernVariableTarget(scope: target.scope, characterId: id); + case TavernVariableScope.script: + final owner = target.scriptOwner ?? context.value.script; + if (owner == null) { + throw _scriptError(TavernScriptErrorCode.noContext, 'variables', 'No script owner is bound.'); + } + _checkOwnerIdentity(owner); + resolved = TavernVariableTarget(scope: target.scope, scriptOwner: owner); + case TavernVariableScope.chat || TavernVariableScope.message: + final id = target.chatId ?? target.messageRef?.chatId ?? context.value.chatId; + if (id == null) { + throw _scriptError(TavernScriptErrorCode.noContext, 'variables', 'No chat is bound.'); + } + if (target.messageRef != null && target.messageRef!.chatId != id) { + invalid('Message and chat identities disagree.'); + } + final chat = engine._store.chat(id); + capture = _ScriptChatCapture(context, id, _pathRevision(id), engine._historyTo(id, chat.currentLeafId)); + if (target.scope == TavernVariableScope.chat) { + resolved = TavernVariableTarget(scope: target.scope, chatId: id); + break; + } + var ref = target.messageRef; + if (ref == null || target.messageId != null) { + final requested = target.messageId ?? -1; + final index = requested < 0 ? capture.path.length + requested : requested; + if (index < 0 || index >= capture.path.length) { + throw _scriptError( + TavernScriptErrorCode.range, + 'variables', + 'The requested message is outside the captured path.', + ); + } + final numbered = TavernScriptMessageRef(chatId: id, nodeId: capture.path[index].message.nodeId); + if (ref != null && ref != numbered) { + invalid('Message number and stable reference disagree.'); + } + ref = numbered; + } + engine._store.node(id, ref.nodeId); + resolved = TavernVariableTarget(scope: target.scope, chatId: id, messageRef: ref); + } + return _ResolvedScriptVariable( + context, + resolved, + capture: capture, + requireSamePath: + target.scope == TavernVariableScope.message && (target.messageRef == null || target.messageId != null), + ); + } + + Future _getVariables(String contextId, TavernVariableTarget target) => + _bridgeOperation(() => _readVariable(_resolveTarget(_context(contextId), target))); + + Future _replaceVariables( + String contextId, + TavernVariableTarget target, + TavernJsonObject value, { + String? expectedVersion, + }) { + final captured = _captureScriptObject(value); + return _bridgeOperation(() { + final resolved = _resolveTarget(_context(contextId), target); + return _writeVariable(resolved, (_) => captured, expectedVersion: expectedVersion); + }); + } + + Future _updateVariablesWith( + String contextId, + TavernVariableTarget target, + FutureOr Function(TavernJsonObject value) updater, + ) async { + late _ResolvedScriptVariable resolved; + try { + resolved = _resolveTarget(_context(contextId), target); + } catch (error, stack) { + Error.throwWithStackTrace(_scriptFailure(error), stack); + } + final before = await _bridgeOperation(() => _readVariable(resolved)); + _context(contextId); + final token = _ScriptCancellation(); + resolved.context.callbacks.add(token); + late Map replacement; + try { + // The arbitrary user Future is not an engine storage operation. close() + // cancels this wait, while separately draining real accepted reads/writes. + final answer = await Future.any([ + Future.sync(() => updater(before.value)), + token.whenCancelled.then( + (_) => throw _scriptError( + TavernScriptErrorCode.cancelled, + 'updateVariablesWith', + 'The callback context was closed.', + contextId: contextId, + ), + ), + ]); + replacement = _captureScriptObject(answer); + } on TavernScriptException { + rethrow; + } catch (error) { + throw _scriptError( + TavernScriptErrorCode.hookFailure, + 'updateVariablesWith', + 'The updater failed (${error.runtimeType}).', + contextId: contextId, + ); + } finally { + resolved.context.callbacks.remove(token); + } + _context(contextId); + return _bridgeOperation(() => _writeVariable(resolved, (_) => replacement, expectedVersion: before.version)); + } + + Future _mutateVariables( + String contextId, + TavernVariableTarget target, + List changes, { + String? expectedVersion, + }) { + final captured = List.of(changes); + return _bridgeOperation(() async { + for (final change in captured) { + if (change.pathTokens.isEmpty || + change.pathTokens.any((token) => token is! String && (token is! int || token < 0)) || + (change.kind != TavernVariableChangeKind.remove && !change.hasValue) || + (change.kind == TavernVariableChangeKind.remove && change.hasValue)) { + throw _scriptError( + TavernScriptErrorCode.invalidArgument, + 'mutateVariables', + 'Invalid path tokens or value presence.', + ); + } + } + final resolved = _resolveTarget(_context(contextId), target); + final results = []; + final nativeGlobal = resolved.target.scope == TavernVariableScope.global; + final snapshot = await _writeVariable( + resolved, + (raw) { + // The global path plan receives a validated runtime projection, which + // can retain native Infinity even though its public JSON value is null. + final next = nativeGlobal ? _runtimeCopy(raw.value! as Map) : _variableObject(raw); + for (final change in captured) { + results.add(_applyVariableChange(next, change, jsonComparison: nativeGlobal)); + } + return next; + }, + expectedVersion: expectedVersion, + mergeNativeGlobals: nativeGlobal, + ); + return TavernVariableMutation(snapshot: snapshot, results: results); + }); + } + + Future _readVariable( + _ResolvedScriptVariable resolved, { + void Function(String version)? onReadVersion, + }) async { + final target = resolved.target; + _context(resolved.context.value.id); + final snapshot = await _readStoredVariable( + target, + (raw) { + final version = _variableVersion(target, raw); + onReadVersion?.call(version); + return TavernVariableSnapshot(resolvedTarget: target, version: version, value: _variableObject(raw)); + }, + onUnavailable: (error) { + onReadVersion?.call(_variableVersionForFingerprint(target, _unavailableVariableFingerprint(error))); + }, + ); + _context(resolved.context.value.id); + return snapshot; + } + + Future _readStoredVariable( + TavernVariableTarget target, + T Function(_ScriptRawVariable raw) read, { + void Function(TavernScriptException error)? onUnavailable, + }) async { + T inspect(_ScriptRawVariable Function() load) { + late final _ScriptRawVariable raw; + try { + raw = load(); + } on TavernScriptException catch (error) { + // A malformed outer carrier also has a commit-observed token. Record + // it inside the same resource queue, before another writer can run. + onUnavailable?.call(error); + rethrow; + } + return read(raw); + } + + switch (target.scope) { + case TavernVariableScope.global: + return engine._settingsDocument((document) async => inspect(() => _fieldVariable(document, 'globalVariables'))); + case TavernVariableScope.chat: + return inspect(() => _ScriptRawVariable(true, engine._store.chat(target.chatId!).variables)); + case TavernVariableScope.message: + final ref = target.messageRef!; + return inspect(() => _fieldVariable(engine._stateAt(ref.chatId, ref.nodeId), 'messageVariables')); + case TavernVariableScope.character: + return engine._libraryOperation(() async { + final record = await engine._library.read(target.characterId!); + if (record == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'variables', 'Character not found.'); + } + return inspect(() { + final extensions = _optionalObject(record.card.data, 'extensions'); + final helper = _optionalObject(extensions, 'tavern_helper'); + return _fieldVariable(helper, 'variables'); + }); + }); + case TavernVariableScope.script: + final owner = target.scriptOwner!; + if (owner.kind == TavernScriptOwnerKind.global) { + return engine._settingsDocument( + (document) async => inspect(() => _fieldVariable(_optionalObject(document, 'scriptData'), owner.scriptId)), + ); + } + if (owner.kind == TavernScriptOwnerKind.character) { + return engine._libraryOperation(() async { + final record = await engine._library.read(owner.assetId!); + if (record == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'variables', 'Script character not found.'); + } + return inspect(() => _scriptData(_optionalObject(record.card.data, 'extensions'), owner.scriptId)); + }); + } + return engine._presetOperation( + () => inspect( + () => _scriptData(_optionalObject(engine._requirePreset(owner.assetId!).raw, 'extensions'), owner.scriptId), + ), + ); + } + } + + Future _writeVariable( + _ResolvedScriptVariable resolved, + Map Function(_ScriptRawVariable raw) transform, { + String? expectedVersion, + bool mergeNativeGlobals = false, + }) async { + final target = resolved.target; + _context(resolved.context.value.id); + final globalThrough = engine._globalVariables.revision; + TavernVariableSnapshot result( + Map actual, { + TavernStateEditResult? stateEdit, + bool beforeCommit = false, + }) => TavernVariableSnapshot( + resolvedTarget: target, + version: beforeCommit + ? _anticipatedVariableVersion(target, _ScriptRawVariable(true, actual)) + : _variableVersion(target, _ScriptRawVariable(true, actual)), + value: actual, + stateEdit: stateEdit, + ); + void checkVersion(_ScriptRawVariable current) { + _context(resolved.context.value.id); + final currentVersion = _variableVersion(target, current); + if (expectedVersion != null && expectedVersion != currentVersion) { + throw _scriptError( + TavernScriptErrorCode.revisionConflict, + 'variables', + 'The target value changed before commit.', + contextId: resolved.context.value.id, + ); + } + } + + Map prepare(_ScriptRawVariable current) { + checkVersion(current); + return _captureScriptObject(transform(current)); + } + + late TavernVariableSnapshot saved; + var committed = false; + try { + switch (target.scope) { + case TavernVariableScope.global: + saved = await engine._settingsDocument((document) async { + final current = _fieldVariable(document, 'globalVariables'); + late Map runtime; + late Map value; + if (mergeNativeGlobals) { + // CAS belongs to the actual committed carrier. Only after that + // check may a path mutation include pre-call native intent. + checkVersion(current); + _variableObject(current); + runtime = _runtimeCopy( + transform(_ScriptRawVariable(true, engine._globalVariables.project(globalThrough))), + ); + value = _copy(runtime); + } else { + value = prepare(current); + runtime = value; + } + final next = _copy(document)..['globalVariables'] = value; + await engine._commitSettingsDocument(next, globals: (runtime: runtime, through: globalThrough)); + committed = true; + return result(value); + }); + case TavernVariableScope.chat: + saved = await _dataWrite(resolved.capture!, () { + late ({String? leaf, Map values}) adopted; + return engine._store.transaction( + () { + final chat = engine._store.chat(target.chatId!); + final value = prepare(_ScriptRawVariable(true, chat.variables)); + final leaf = chat.currentLeafId; + if (leaf != null) { + final state = engine._stateAt(chat.id, leaf)..['variables'] = value; + engine._store.writeNodeState(chat.id, leaf, state); + engine._writeNodeState(chat.id, leaf, state); + } else if (engine._store.groupChat(chat.id) != null) { + final state = engine._store.groupInitialState(chat.id)..['variables'] = value; + engine._store.setGroupInitialState(chat.id, state); + } + engine._store.updateChat(chat.id, variables: value); + adopted = (leaf: leaf, values: _copy(value)); + return result(value, beforeCommit: true); + }, + onCommitted: () { + committed = true; + engine._liveVariables[target.chatId!] = adopted; + }, + ); + }); + case TavernVariableScope.message: + final ref = target.messageRef!; + final dataContext = await _prepareDataContext(ref.chatId); + saved = await _dataWrite( + resolved.capture!, + () => engine._store.transaction( + () { + _checkChatCapture(resolved.capture!, requireSamePath: resolved.requireSamePath); + final baseline = engine._stateAt(ref.chatId, ref.nodeId); + final value = prepare(_fieldVariable(baseline, 'messageVariables')); + final prepared = _prepareMessageData( + dataContext, + nodeId: ref.nodeId, + baseline: baseline, + data: value, + extra: engine._store.rawNodeExtra(ref.chatId, ref.nodeId), + ); + _commitMessageData(ref.chatId, ref.nodeId, prepared); + engine._restoreVariables(ref.chatId); + return result( + _captureScriptObject(prepared.state['messageVariables']), + stateEdit: prepared.stateEdit, + beforeCommit: true, + ); + }, + onCommitted: () { + committed = true; + engine._liveVariables.remove(ref.chatId); + }, + ), + ); + case TavernVariableScope.character: + saved = await engine._libraryOperation(() async { + final id = target.characterId!; + final record = await engine._library.read(id); + if (record == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'variables', 'Character not found.'); + } + final extensions = _optionalObject(record.card.data, 'extensions'); + final helper = _optionalObject(extensions, 'tavern_helper'); + final value = prepare(_fieldVariable(helper, 'variables')); + helper['variables'] = value; + extensions['tavern_helper'] = helper; + await _saveScriptCharacter(id, extensions); + committed = true; + return result(value); + }); + case TavernVariableScope.script: + final owner = target.scriptOwner!; + if (owner.kind == TavernScriptOwnerKind.global) { + saved = await engine._settingsDocument((document) async { + final container = _optionalObject(document, 'scriptData'); + final value = prepare(_fieldVariable(container, owner.scriptId)); + container[owner.scriptId] = value; + final next = _copy(document)..['scriptData'] = container; + await engine._commitSettingsDocument(next); + committed = true; + engine._document['scriptData'] = _copy(container); + return result(value); + }); + } else if (owner.kind == TavernScriptOwnerKind.character) { + saved = await engine._libraryOperation(() async { + final id = owner.assetId!; + final record = await engine._library.read(id); + if (record == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'variables', 'Script character not found.'); + } + final extensions = _optionalObject(record.card.data, 'extensions'); + final entry = _scriptEntry(extensions, owner.scriptId); + final value = prepare(_fieldVariable(entry, 'data')); + entry['data'] = value; + await _saveScriptCharacter(id, extensions); + committed = true; + return result(value); + }); + } else { + saved = await engine._presetOperation(() { + final original = engine._requirePreset(owner.assetId!); + final raw = original.raw; + final extensions = _optionalObject(raw, 'extensions'); + final entry = _scriptEntry(extensions, owner.scriptId); + final value = prepare(_fieldVariable(entry, 'data')); + entry['data'] = value; + raw['extensions'] = extensions; + engine._store.transaction( + () => engine._savePresetEdit(original, raw), + onCommitted: () { + committed = true; + }, + ); + return result(value); + }); + } + } + } on TavernScriptException catch (error) { + if (error.committed) { + for (final context in contexts.values.toList()) { + if (context.closed) { + continue; + } + try { + await _refreshContext(context); + } catch (_) { + /* Preserve the committed storage failure. */ + } + } + } + rethrow; + } on StateError { + throw _scriptError( + committed ? TavernScriptErrorCode.storage : TavernScriptErrorCode.notFound, + 'variables', + committed ? 'Storage reported a failure after commit.' : 'The captured target no longer exists.', + contextId: resolved.context.value.id, + nodeId: target.messageRef?.nodeId, + committed: committed, + ); + } catch (error) { + throw _scriptError( + TavernScriptErrorCode.storage, + 'variables', + 'Variable storage failed (${error.runtimeType}).', + contextId: resolved.context.value.id, + nodeId: target.messageRef?.nodeId, + committed: committed, + ); + } + TavernScriptException? cacheFailure; + for (final context in contexts.values.toList()) { + if (!context.closed) { + try { + await _refreshContext(context); + } on TavernScriptException catch (error) { + cacheFailure ??= error; + } + } + } + if (cacheFailure != null) { + throw cacheFailure; + } + return saved; + } + + Future _saveScriptCharacter(String id, Map extensions) async { + if (engine._deletingCharacters.contains(id)) { + throw _scriptError(TavernScriptErrorCode.notFound, 'variables', 'Character deletion is running.'); + } + try { + final record = await engine._library.updateScriptStorage(id, extensions); + engine._characterCache = null; + engine._cacheCharacterDetail(id, Future.value(record)); + engine._directors.characterChanged(id); + engine._notifyLists(); + } catch (error) { + // card.json can commit before its derived summary fails. Evict and read + // the authoritative file; never publish the older cached character. + engine._characterCache = null; + unawaited(engine._characterDetails.remove(id)); + StoredCharacter? actual; + try { + actual = await engine._library.read(id); + } catch (_) { + /* Preserve the original storage issue. */ + } + if (actual != null) { + engine._cacheCharacterDetail(id, Future.value(actual)); + } + final committed = actual != null && _stateJsonEqual(_optionalObject(actual.card.data, 'extensions'), extensions); + if (committed) { + engine._directors.characterChanged(id); + } + throw _scriptError( + TavernScriptErrorCode.storage, + 'variables', + 'Character storage failed (${error.runtimeType}).', + committed: committed, + ); + } + } + + Future _validateScriptOwner(TavernScriptOwner owner) async { + _checkOwnerIdentity(owner); + if (owner.kind == TavernScriptOwnerKind.global) { + return; + } + await _readStoredVariable(TavernVariableTarget(scope: TavernVariableScope.script, scriptOwner: owner), (_) {}); + } + + Future<_ScriptDataContext> _prepareDataContext(String chatId) async { + while (true) { + final chat = engine._store.chat(chatId); + final revision = engine._directors._cardRevisions[chat.characterId] ?? 0; + final record = await engine._record(chat.characterId); + if (record == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'data', 'The host character does not exist.'); + } + if (revision != (engine._directors._cardRevisions[chat.characterId] ?? 0)) { + continue; + } + return _ScriptDataContext( + chatId, + chat.characterId, + revision, + _copy(record.card.data), + _stateDefinition(record.card.data), + engine._effectiveSettings(chat, engine._chatCodec.metadataForNode(engine._store, chatId)), + ); + } + } + + Map _initialMessageState(_ScriptDataContext dataContext) { + _checkDataContext(dataContext); + final chat = engine._store.chat(dataContext.chatId); + final state = engine._store.groupChat(chat.id) == null + ? _emptyChatState(engine._chatCodec.metadataForNode(engine._store, chat.id)) + : engine._store.groupInitialState(chat.id); + state['variables'] = _copy(chat.variables); + _initializeDshState(dataContext.definition, state, dataContext.settings['stateEnabled'] != false); + return state; + } + + void _checkDataContext(_ScriptDataContext dataContext) { + final chat = engine._store.chat(dataContext.chatId); + if (chat.characterId != dataContext.hostId || + engine._deletingCharacters.contains(dataContext.hostId) || + dataContext.hostRevision != (engine._directors._cardRevisions[dataContext.hostId] ?? 0)) { + throw _scriptError(TavernScriptErrorCode.revisionConflict, 'data', 'The captured host definition changed.'); + } + } + + _ScriptPreparedData _prepareMessageData( + _ScriptDataContext context, { + required String? nodeId, + required Map baseline, + required Map data, + Map extra = const {}, + }) { + _checkDataContext(context); + final requested = _captureScriptObject(data); + final old = baseline['messageVariables']; + final oldObject = old is Map ? old : null; + final sameDshgo = + oldObject != null && + oldObject.containsKey('dshgo') == requested.containsKey('dshgo') && + _stateJsonEqual(oldObject['dshgo'], requested['dshgo']); + final next = _copy(baseline)..['messageVariables'] = requested; + if (!context.definition.hasSchema || + sameDshgo || + (oldObject == null && !baseline.containsKey('messageVariables') && !requested.containsKey('dshgo'))) { + return _ScriptPreparedData(next, null, null, null); + } + final input = _StateInput(baseline); + final invocation = _newStateId(); + final issues = [...input.issues]; + var receipts = >[]; + var changes = >[]; + var effects = >[]; + var applied = false; + var changed = false; + Map candidate = next; + if (input.writable) { + final session = context.definition.startSession( + valueExists: input.valueExists, + value: input.value, + runtimeExists: input.runtimeExists, + runtime: input.runtime, + ); + final remove = !requested.containsKey('dshgo'); + final operation = session.apply( + [ + {'op': remove ? 'remove' : 'set', 'path': '', 'why': 'script', if (!remove) 'value': requested['dshgo']}, + ], + actor: state_core.StateActor.user, + source: 'script', + ); + final result = session.finish(runRules: operation.acceptedCount > 0); + receipts = result.receipts; + issues.addAll(result.issues); + applied = operation.acceptedCount == 1 && operation.rejectedCount == 0 && result.valid; + if (applied) { + changed = result.changed; + changes = result.changes; + effects = _identifiedEffects(result.effects, invocation); + candidate = _stateResultSnapshot(next, result, effects, const {}); + } + } + final audit = _stateOwnAudit(invocation, changes, effects, issues); + final panel = _statePanel( + context.chatId, + nodeId, + applied ? candidate : baseline, + applied ? _stateNodeExtra(extra, candidate, audit) : extra, + context.definition, + context.settings, + protection: _SecretProtection(context.hostData, applied ? candidate : baseline), + ); + final stateEdit = TavernStateEditResult( + applied: applied, + changed: changed, + snapshot: panel, + receipts: receipts, + issues: issues, + ); + if (!applied) { + throw _scriptError( + TavernScriptErrorCode.stateValidation, + 'messageData', + 'The complete message-variable replacement was rejected.', + nodeId: nodeId, + stateEdit: stateEdit, + ); + } + return _ScriptPreparedData(candidate, stateEdit, audit, { + 'version': 1, + 'invocationId': invocation, + 'source': 'script', + 'definitionFingerprint': context.definition.fingerprint, + 'committed': true, + 'changed': changed, + 'receipts': receipts, + 'changes': changes, + 'effects': effects, + 'issues': issues, + }); + } + + void _commitMessageData(String chatId, String nodeId, _ScriptPreparedData prepared) { + engine._store.writeNodeState(chatId, nodeId, prepared.state); + final extra = engine._store.rawNodeExtra(chatId, nodeId); + engine._store.updateMessage( + chatId, + nodeId, + extra: { + ...extra, + 'dshgo': {..._map(extra['dshgo']), ...?prepared.audit, 'tavernState': _copy(prepared.state)}, + }, + ); + if (prepared.run case final run?) { + engine._store.writeAgentRun( + StoredAgentRun( + id: '${run['invocationId']}:script', + chatId: chatId, + nodeId: nodeId, + kind: 'state.script', + at: engine._clock(), + result: run, + ), + ); + } + } +} + +void _checkOwnerIdentity(TavernScriptOwner owner) { + if (owner.scriptId.isEmpty || + (owner.kind == TavernScriptOwnerKind.global + ? owner.assetId != null + : owner.assetId == null || owner.assetId!.isEmpty)) { + throw _scriptError(TavernScriptErrorCode.invalidArgument, 'scriptOwner', 'Invalid persistent script identity.'); + } +} + +Map _optionalObject(Map parent, String key) { + if (!parent.containsKey(key)) { + return {}; + } + final value = parent[key]; + if (value is! Map) { + throw _scriptError( + TavernScriptErrorCode.shapeMismatch, + 'variables', + 'The $key carrier is not an object.', + fieldPath: key, + ); + } + return _copy(value); +} + +_ScriptRawVariable _fieldVariable(Map object, String key) => + _ScriptRawVariable(object.containsKey(key), object[key]); + +Map _variableObject(_ScriptRawVariable raw) { + if (!raw.present) { + return {}; + } + if (raw.value is! Map) { + throw _scriptError( + TavernScriptErrorCode.shapeMismatch, + 'variables', + 'The stored variable target is not an object.', + ); + } + return _captureScriptObject(raw.value); +} + +String _targetKey(TavernVariableTarget target) => jsonEncode([ + target.scope.name, + target.characterId, + target.chatId ?? target.messageRef?.chatId, + target.messageRef?.nodeId, + target.scriptOwner?.kind.name, + target.scriptOwner?.assetId, + target.scriptOwner?.scriptId, +]); + +Object? _canonicalScriptValue(Object? value) { + if (value is Map) { + return {for (final key in value.keys.toList()..sort()) key: _canonicalScriptValue(value[key])}; + } + if (value is List) { + return [for (final item in value) _canonicalScriptValue(item)]; + } + return value; +} + +extension _ScriptVariableVersions on _ScriptCoordinator { + String _anticipatedVariableVersion(TavernVariableTarget target, _ScriptRawVariable raw) { + final entry = _variableVersions[_targetKey(target)]!; + final revision = entry.revision + (entry.fingerprint == _variableFingerprint(raw) ? 0 : 1); + // SQL can prepare its detached result before COMMIT without adopting a + // revision early. The synchronous commit observer adopts exactly this + // change; a rollback leaves the real counter untouched. + return '$epoch:variable:${entry.identity}:$revision'; + } + + String _variableVersion(TavernVariableTarget target, _ScriptRawVariable raw) { + return _variableVersionForFingerprint(target, _variableFingerprint(raw)); + } + + String _variableVersionForFingerprint(TavernVariableTarget target, String fingerprint) { + final key = _targetKey(target); + final entry = _variableVersions.putIfAbsent( + key, + () => _ScriptVariableVersion(target, ++_nextVariableIdentity, fingerprint), + ); + if (entry.fingerprint != fingerprint) { + entry.fingerprint = fingerprint; + entry.revision++; + } + if (target.scope == TavernVariableScope.message) { + entry.messageTreeRevision = engine._store.treeRevision(target.messageRef!.chatId); + } + return '$epoch:variable:${entry.identity}:${entry.revision}'; + } + + String? _currentVariableVersion(String key) { + final entry = _variableVersions[key]; + return entry == null ? null : '$epoch:variable:${entry.identity}:${entry.revision}'; + } + + void _observeVariable(_ScriptVariableVersion entry, _ScriptRawVariable Function() read) { + // These observers run after a real commit. A deleted/malformed target + // invalidates its old token, but no read/shape error can turn COMMIT into + // an apparent rollback or prevent notification of other targets. + String fingerprint; + try { + fingerprint = _variableFingerprint(read()); + } catch (error) { + fingerprint = _unavailableVariableFingerprint(error); + } + if (entry.fingerprint != fingerprint) { + entry.fingerprint = fingerprint; + entry.revision++; + } + } + + void documentCommitted(Map document) { + if (_closed) { + return; + } + for (final entry in _variableVersions.values) { + final target = entry.target; + if (target.scope == TavernVariableScope.global) { + _observeVariable(entry, () => _fieldVariable(document, 'globalVariables')); + } else if (target.scope == TavernVariableScope.script && + target.scriptOwner!.kind == TavernScriptOwnerKind.global) { + _observeVariable( + entry, + () => _fieldVariable(_optionalObject(document, 'scriptData'), target.scriptOwner!.scriptId), + ); + } + } + } + + void cardCommitted(String id, Map data) { + if (_closed) { + return; + } + for (final entry in _variableVersions.values) { + final target = entry.target; + if (target.scope == TavernVariableScope.character && target.characterId == id) { + _observeVariable( + entry, + () => _fieldVariable(_optionalObject(_optionalObject(data, 'extensions'), 'tavern_helper'), 'variables'), + ); + } else if (target.scope == TavernVariableScope.script && + target.scriptOwner!.kind == TavernScriptOwnerKind.character && + target.scriptOwner!.assetId == id) { + _observeVariable(entry, () => _scriptData(_optionalObject(data, 'extensions'), target.scriptOwner!.scriptId)); + } + } + } + + void storeCommitted(Set chatIds, bool presetsChanged) { + if (_closed) { + return; + } + for (final entry in _variableVersions.values) { + final target = entry.target; + if (target.scope == TavernVariableScope.chat && chatIds.contains(target.chatId)) { + // updateChat(variables) also commits for an empty path and need not + // advance tree_revision. Always inspect this small chat carrier. + _observeVariable(entry, () => _ScriptRawVariable(true, engine._store.chat(target.chatId!).variables)); + } else if (target.scope == TavernVariableScope.message && chatIds.contains(target.messageRef!.chatId)) { + final ref = target.messageRef!; + int? revision; + try { + revision = engine._store.treeRevision(ref.chatId); + } catch (_) { + /* Deleted chat. */ + } + // Body checkpoints do not touch a saved state/portable override. All + // state writers and updateMessage(extra) do advance this revision. + if (revision != null && revision == entry.messageTreeRevision) { + continue; + } + _observeVariable(entry, () => _fieldVariable(engine._stateAt(ref.chatId, ref.nodeId), 'messageVariables')); + entry.messageTreeRevision = revision; + } else if (presetsChanged && + target.scope == TavernVariableScope.script && + target.scriptOwner!.kind == TavernScriptOwnerKind.preset) { + final owner = target.scriptOwner!; + _observeVariable( + entry, + () => _scriptData(_optionalObject(engine._requirePreset(owner.assetId!).raw, 'extensions'), owner.scriptId), + ); + } + } + } +} + +String _variableFingerprint(_ScriptRawVariable raw) => jsonEncode([raw.present, _canonicalScriptValue(raw.value)]); + +String _unavailableVariableFingerprint(Object error) => error is TavernScriptException + ? jsonEncode(['unavailable', error.code.name, error.message, error.fieldPath]) + : jsonEncode(['unavailable', error.runtimeType.toString()]); + +Map _scriptEntry(Map extensions, String scriptId) { + final helper = extensions['tavern_helper']; + Object? scripts; + if (helper is Map && helper['scripts'] != null) { + scripts = helper['scripts']; + } else { + scripts = extensions['TavernHelper_scripts']; + } + if (scripts == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'script', 'The script container has no matching script.'); + } + if (scripts is! List) { + throw _scriptError(TavernScriptErrorCode.shapeMismatch, 'script', 'Scripts must be an array.'); + } + final found = >[]; + void visit(List values) { + for (final value in values) { + if (value is! Map) { + continue; + } + final nested = value['scripts'] ?? value['children']; + final folder = value['type'] == 'folder' || (value['type'] != 'script' && nested is List); + if (folder) { + if (nested is List) { + visit(nested); + } + } else if (value['id'] == scriptId) { + found.add(value); + } + } + } + + visit(scripts); + if (found.isEmpty) { + throw _scriptError(TavernScriptErrorCode.notFound, 'script', 'Script not found in its owner.'); + } + if (found.length > 1) { + throw _scriptError(TavernScriptErrorCode.ambiguousName, 'script', 'Script id is repeated in its owner.'); + } + return found.single; +} + +_ScriptRawVariable _scriptData(Map extensions, String id) => + _fieldVariable(_scriptEntry(extensions, id), 'data'); + +TavernVariableChangeResult _applyVariableChange( + Map root, + TavernVariableChange change, { + bool jsonComparison = false, +}) { + Object? parent = root; + for (final token in change.pathTokens.take(change.pathTokens.length - 1)) { + if (parent is Map && token is String && parent.containsKey(token)) { + parent = parent[token]; + } else if (parent is List && token is int && token >= 0 && token < parent.length) { + parent = parent[token]; + } else { + if (change.kind == TavernVariableChangeKind.remove) { + return TavernVariableChangeResult( + kind: change.kind, + pathTokens: change.pathTokens, + existed: false, + changed: false, + ); + } + throw _scriptError( + TavernScriptErrorCode.range, + 'mutateVariables', + 'A path parent is missing or not a container.', + ); + } + } + final key = change.pathTokens.last; + late bool existed; + Object? before; + if (parent is Map && key is String) { + existed = parent.containsKey(key); + before = parent[key]; + if (change.kind == TavernVariableChangeKind.remove) { + parent.remove(key); + } else if (change.kind == TavernVariableChangeKind.set || !existed) { + parent[key] = change.value; + } + } else if (parent is List && key is int && key >= 0) { + existed = key < parent.length; + before = existed ? parent[key] : null; + if (change.kind == TavernVariableChangeKind.remove) { + if (existed) { + parent.removeAt(key); + } + } else if (key > parent.length) { + throw _scriptError( + TavernScriptErrorCode.range, + 'mutateVariables', + 'Sparse array writes are not native JSON operations.', + ); + } else if (!existed) { + parent.add(change.value); + } else if (change.kind == TavernVariableChangeKind.set) { + parent[key] = change.value; + } + } else { + throw _scriptError( + TavernScriptErrorCode.invalidArgument, + 'mutateVariables', + 'Path token does not match its container.', + ); + } + final changed = change.kind == TavernVariableChangeKind.remove + ? existed + : change.kind == TavernVariableChangeKind.insertIfAbsent && existed + ? false + : !existed || !_stateJsonEqual(jsonComparison ? _jsonCompatible(before) : before, change.value); + return TavernVariableChangeResult( + kind: change.kind, + pathTokens: change.pathTokens, + existed: existed, + changed: changed, + ); +} diff --git a/packages/dsh_tavern/lib/src/engine_scripts.dart b/packages/dsh_tavern/lib/src/engine_scripts.dart new file mode 100644 index 0000000000..25707fced9 --- /dev/null +++ b/packages/dsh_tavern/lib/src/engine_scripts.dart @@ -0,0 +1,1431 @@ +part of 'engine.dart'; + +class _ScriptContextState { + _ScriptContextState(this.value); + final TavernScriptContext value; + bool closed = false; + int seq = 0; + TavernScriptSnapshot? view; + Future lifecycle = Future.value(); + Future? refreshing; + bool refreshAgain = false; + final pending = {}; + final callbacks = <_ScriptCancellation>{}; +} + +class _ScriptChatCapture { + _ScriptChatCapture(this.context, this.chatId, this.revision, List path) : path = List.unmodifiable(path); + final _ScriptContextState context; + final String chatId; + final String revision; + final List path; +} + +class _ScriptLifecycle { + _ScriptLifecycle({required this.name, this.nodeRef, this.location, List args = const []}) + : args = _captureScriptJson(args)! as List; + final String name; + final TavernScriptMessageRef? nodeRef; + final ({int? messageId, int? swipeId, String? pathRevision})? location; + final List args; +} + +class _ScriptSent { + _ScriptSent(this.ref, this.messageId, this.pathRevision); + final TavernScriptMessageRef ref; + final int messageId; + final String pathRevision; + bool published = false; +} + +class _ScriptCancellation implements TavernScriptCancellation { + final _done = Completer(); + String reason = 'cancelled'; + @override + bool get isCancelled => _done.isCompleted; + @override + Future get whenCancelled => _done.future; + void cancel([String why = 'cancelled']) { + if (isCancelled) { + return; + } + reason = why; + _done.complete(); + } +} + +class _ScriptCoordinator implements TavernScriptBridge { + _ScriptCoordinator(this.engine); + final TavernEngine engine; + final epoch = 'engine-${_newStateId()}'; + final contexts = {}; + final _closedContexts = {}; + final _events = StreamController.broadcast(); + final _deliveries = >{}; + final _nativeDirty = {}; + final _variableVersions = {}; + int _nextVariableIdentity = 0; + TavernScriptHost? _host; + int _hostVersion = 0; + bool _closed = false; + + Future _bridgeOperation(FutureOr Function() action) { + try { + return engine + ._operation(action) + .then( + (value) => value, + onError: (Object error, StackTrace stack) { + Error.throwWithStackTrace(_scriptFailure(error), stack); + }, + ); + } catch (error, stack) { + return Future.error(_scriptFailure(error), stack); + } + } + + TavernScriptException _scriptFailure(Object error) { + if (error is TavernScriptException) { + return error; + } + if (engine._closing || engine._closed || _closed) { + return _scriptError(TavernScriptErrorCode.closedContext, 'bridge', 'The script engine is closed.'); + } + if (error is TavernModelException) { + return TavernScriptException( + code: error.kind == TavernFailure.busy + ? TavernScriptErrorCode.busy + : error.kind == TavernFailure.cancelled + ? TavernScriptErrorCode.cancelled + : TavernScriptErrorCode.model, + operation: 'bridge', + message: error.detail, + modelFailure: error.kind, + ); + } + if (error is StateError) { + return _scriptError(TavernScriptErrorCode.notFound, 'bridge', 'The captured target is unavailable.'); + } + if (error is ArgumentError) { + return _scriptError(TavernScriptErrorCode.invalidArgument, 'bridge', 'An argument is invalid.'); + } + if (error is FormatException) { + return _scriptError(TavernScriptErrorCode.invalidJson, 'bridge', 'Stored JSON is invalid.'); + } + return _scriptError(TavernScriptErrorCode.storage, 'bridge', 'Bridge storage failed (${error.runtimeType}).'); + } + + @override + TavernScriptCapabilities get capabilities => TavernScriptCapabilities( + adapterVersion: 'p2-b1', + hostAttached: _host != null, + methods: const { + 'openContext', + 'snapshot', + 'closeContext', + 'activateChat', + 'watchEvents', + 'getChatMessages', + 'setChatMessages', + 'createChatMessages', + 'deleteChatMessages', + 'rotateChatMessages', + 'selectSwipe', + 'getVariables', + 'replaceVariables', + 'updateVariablesWith', + 'mutateVariables', + }, + scopes: TavernVariableScope.values.toSet(), + ); + + _ScriptContextState _context(String id) { + if (_closed || engine._closing || engine._closed) { + throw _scriptError(TavernScriptErrorCode.closedContext, 'context', 'The script engine is closed.', contextId: id); + } + final value = contexts[id]; + if (value == null || value.closed) { + throw _scriptError( + _closedContexts.contains(id) ? TavernScriptErrorCode.closedContext : TavernScriptErrorCode.noContext, + 'context', + 'The script context is not open.', + contextId: id, + ); + } + return value; + } + + String _pathRevision(String chatId) => '$epoch:path:${engine._store.treeRevision(chatId)}'; + + _ScriptChatCapture _captureChat(String contextId) { + final context = _context(contextId); + final chatId = context.value.chatId; + if (chatId == null) { + throw _scriptError(TavernScriptErrorCode.noContext, 'chat', 'This context has no chat.', contextId: contextId); + } + try { + final chat = engine._store.chat(chatId); + return _ScriptChatCapture(context, chatId, _pathRevision(chatId), engine._historyTo(chatId, chat.currentLeafId)); + } on StateError { + throw _scriptError( + TavernScriptErrorCode.notFound, + 'chat', + 'The captured chat no longer exists.', + contextId: contextId, + ); + } + } + + void _checkChatCapture(_ScriptChatCapture capture, {String? expectedRevision, bool requireSamePath = false}) { + _context(capture.context.value.id); + final chat = engine._store.chat(capture.chatId); + final current = _ScriptChatCapture( + capture.context, + capture.chatId, + _pathRevision(capture.chatId), + engine._historyTo(capture.chatId, chat.currentLeafId), + ); + if ((expectedRevision != null && current.revision != expectedRevision) || + (requireSamePath && + !_stateJsonEqual( + [for (final node in capture.path) node.message.nodeId], + [for (final node in current.path) node.message.nodeId], + ))) { + throw _scriptError( + TavernScriptErrorCode.revisionConflict, + 'write', + 'The captured message path changed.', + contextId: capture.context.value.id, + ); + } + } + + @override + void attachHost(TavernScriptHost? host) { + if (_closed || engine._closing || engine._closed) { + throw _scriptError(TavernScriptErrorCode.closedContext, 'attachHost', 'The script engine is closed.'); + } + if (identical(_host, host)) { + return; + } + _hostVersion++; + for (final context in contexts.values) { + for (final token in context.pending.values) { + token.cancel('hostUnavailable'); + } + } + _host = host; + resourcesChanged(); + } + + @override + Future openContext(TavernScriptContextSpec spec) => _bridgeOperation(() async { + if (spec.instanceId.isEmpty || spec.chatId == '' || spec.characterId == '' || spec.nodeId == '') { + throw _scriptError(TavernScriptErrorCode.invalidArgument, 'openContext', 'Context identities must be nonempty.'); + } + if (spec.nodeId != null && spec.chatId == null) { + throw _scriptError(TavernScriptErrorCode.noContext, 'openContext', 'A node requires its chat identity.'); + } + String? characterId = spec.characterId; + if (spec.chatId case final chatId?) { + try { + final chat = engine._store.chat(chatId); + if (spec.nodeId case final nodeId?) { + engine._store.node(chatId, nodeId); + } + if (characterId == null && engine._store.groupChat(chatId) == null) { + characterId = chat.characterId; + } + } on StateError { + throw _scriptError(TavernScriptErrorCode.notFound, 'openContext', 'The specified chat or node does not exist.'); + } + } + if (characterId != null && await engine._record(characterId) == null) { + throw _scriptError(TavernScriptErrorCode.notFound, 'openContext', 'The specified character does not exist.'); + } + final owner = spec.script; + if (owner != null) { + await _validateScriptOwner(owner); + } + final context = _ScriptContextState( + TavernScriptContext( + id: 'context-${_newStateId()}', + engineEpoch: epoch, + instanceId: spec.instanceId, + chatId: spec.chatId, + characterId: characterId, + nodeId: spec.nodeId, + script: owner, + ), + ); + contexts[context.value.id] = context; + try { + final initial = await _buildView(context); + _context(context.value.id); + context.view = initial; + return context.value; + } catch (_) { + contexts.remove(context.value.id); + context.closed = true; + rethrow; + } + }); + + @override + Future snapshot(String contextId) => _bridgeOperation(() async { + final context = _context(contextId); + // A host can request this while applying a mismatched cache delta. Reading + // a new snapshot must not await the very cache Ack it is meant to repair. + final current = await _buildView(context); + _context(contextId); + return current!; + }); + + @override + Future closeContext(String contextId) => _bridgeOperation(() { + final context = contexts.remove(contextId); + if (context == null) { + if (_closedContexts.contains(contextId)) { + return; + } + throw _scriptError(TavernScriptErrorCode.noContext, 'closeContext', 'Unknown context.', contextId: contextId); + } + context.closed = true; + _closedContexts.add(contextId); + for (final token in context.pending.values) { + token.cancel(); + } + for (final token in context.callbacks) { + token.cancel(); + } + context.view = null; + }); + + @override + Future activateChat(String contextId) => _bridgeOperation(() async { + final capture = _captureChat(contextId); + await _refreshContext(capture.context); + _scheduleLifecycle(capture.context, _ScriptLifecycle(name: 'CHAT_CHANGED', args: [capture.chatId])); + }); + + @override + Stream watchEvents({String? chatId}) { + if (_closed || engine._closing || engine._closed) { + throw _scriptError(TavernScriptErrorCode.closedContext, 'watchEvents', 'The script engine is closed.'); + } + return chatId == null ? _events.stream : _events.stream.where((event) => event.chatId == chatId); + } + + @override + Future getChatMessages( + String contextId, + TavernMessageRange range, { + TavernScriptRole? role, + TavernHiddenFilter hidden = TavernHiddenFilter.all, + bool includeSwipes = false, + }) => _bridgeOperation( + () => _getChatMessages(contextId, range, role: role, hidden: hidden, includeSwipes: includeSwipes), + ); + @override + Future setChatMessages( + String contextId, + List patches, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _bridgeOperation(() => _setChatMessages(contextId, List.of(patches), options: options)); + @override + Future createChatMessages( + String contextId, + List messages, { + int? insertBefore, + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _bridgeOperation( + () => _createChatMessages(contextId, List.of(messages), insertBefore: insertBefore, options: options), + ); + @override + Future deleteChatMessages( + String contextId, + List messageIds, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _bridgeOperation(() => _deleteChatMessages(contextId, List.of(messageIds), options: options)); + @override + Future rotateChatMessages( + String contextId, + int begin, + int middle, + int end, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _bridgeOperation(() => _rotateChatMessages(contextId, begin, middle, end, options: options)); + @override + Future selectSwipe( + String contextId, + TavernScriptMessageRef message, + int swipeId, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _bridgeOperation(() => _selectSwipe(contextId, message, swipeId, options: options)); + @override + Future getVariables(String contextId, TavernVariableTarget target) => + _getVariables(contextId, target); + @override + Future replaceVariables( + String contextId, + TavernVariableTarget target, + TavernJsonObject value, { + String? expectedVersion, + }) => _replaceVariables(contextId, target, value, expectedVersion: expectedVersion); + @override + Future updateVariablesWith( + String contextId, + TavernVariableTarget target, + FutureOr Function(TavernJsonObject value) updater, + ) => _updateVariablesWith(contextId, target, updater); + @override + Future mutateVariables( + String contextId, + TavernVariableTarget target, + List changes, { + String? expectedVersion, + }) => _mutateVariables(contextId, target, changes, expectedVersion: expectedVersion); + + Future _structureWrite(_ScriptChatCapture capture, FutureOr Function() action) { + _context(capture.context.value.id); + return engine._withMutation(capture.chatId, () { + _context(capture.context.value.id); + return action(); + }); + } + + Future _dataWrite(_ScriptChatCapture capture, FutureOr Function() action) { + _context(capture.context.value.id); + final turn = engine._turns[capture.chatId]; + if (turn != null && !turn.scriptWritesAllowed && !turn.modelDrained.isCompleted) { + return _structureWrite(capture, action); + } + final previous = engine._mutations[capture.chatId] ?? Future.value(); + final released = Completer(); + engine._mutations[capture.chatId] = released.future; + return () async { + try { + await previous; + _context(capture.context.value.id); + return await action(); + } finally { + if (identical(engine._mutations[capture.chatId], released.future)) { + unawaited(engine._mutations.remove(capture.chatId)); + } + released.complete(); + } + }(); + } + + Future drainWrites(String chatId, ModelCancellation cancellation) async { + cancellation.check(); + while (true) { + final gate = engine._mutations[chatId]; + if (gate == null) break; + await cancellation.wait(gate); + cancellation.check(); + } + } + + Future _buildView(_ScriptContextState context) async { + if (context.closed) { + return null; + } + while (true) { + final capture = context.value.chatId == null ? null : _captureChat(context.value.id); + final variables = []; + final shapeIssues = >[]; + final observedVersions = {}; + final targets = [ + const TavernVariableTarget(scope: TavernVariableScope.global), + if (context.value.characterId != null) const TavernVariableTarget(scope: TavernVariableScope.character), + if (capture != null) const TavernVariableTarget(scope: TavernVariableScope.chat), + if (capture != null && capture.path.isNotEmpty) + TavernVariableTarget( + scope: TavernVariableScope.message, + messageRef: TavernScriptMessageRef(chatId: capture.chatId, nodeId: capture.path.last.message.nodeId), + ), + if (context.value.script != null) const TavernVariableTarget(scope: TavernVariableScope.script), + ]; + for (final target in targets) { + final resolved = _resolveTarget(context, target); + try { + variables.add( + await _readVariable( + resolved, + onReadVersion: (version) { + observedVersions[_targetKey(resolved.target)] = version; + }, + ), + ); + } on TavernScriptException catch (error) { + if (error.code != TavernScriptErrorCode.shapeMismatch) { + rethrow; + } + shapeIssues.add({ + 'scope': target.scope.name, + 'code': error.code.name, + 'message': error.message, + 'fieldPath': ?error.fieldPath, + 'version': observedVersions[_targetKey(resolved.target)], + }); + } + } + if (context.closed) { + return null; + } + if (capture != null && capture.revision != _pathRevision(capture.chatId)) { + continue; + } + // Every resource read can await a different real storage queue. Before + // taking lastSeq, synchronously check their commit-observed tokens so an + // older value can never be returned with a newer cache water mark. + if (observedVersions.entries.any((entry) => _currentVariableVersion(entry.key) != entry.value)) { + continue; + } + final latest = capture == null ? null : _captureChat(context.value.id); + if (capture != null && + latest != null && + (capture.revision != latest.revision || + capture.path.length != latest.path.length || + capture.path.indexed.any((item) => item.$2.message.nodeId != latest.path[item.$1].message.nodeId))) { + continue; + } + // Streaming body checkpoints intentionally do not advance tree_revision. + // Project fresh StoredNodes in this final synchronous section, after all + // file awaits, instead of pairing an old body with newer variable data. + final messages = latest == null ? [] : _projectMessages(latest, includeSwipes: true); + final chat = capture == null ? null : engine._store.chat(capture.chatId); + final group = chat == null ? null : engine._store.groupChat(chat.id); + final preset = engine._resolvePreset(chat).selection.effectivePresetId; + final memberIds = group == null ? [] : [for (final member in group.members) member.characterId]; + return TavernScriptSnapshot( + context: context.value, + engineEpoch: epoch, + lastSeq: context.seq, + pathRevision: capture?.revision ?? '$epoch:path:none', + messages: messages, + variableSnapshots: variables, + presetId: preset, + personaId: chat?.personaId, + memberCharacterIds: memberIds, + resourceRefs: { + 'presetId': preset, + 'personaId': chat?.personaId, + 'characterId': context.value.characterId, + 'characterRevision': context.value.characterId == null + ? null + : '$epoch:card:${engine._directors._cardRevisions[context.value.characterId] ?? 0}', + 'hostCharacterId': group?.hostCharacterId, + 'memberCharacterIds': memberIds, + 'hostVersion': '$epoch:host:$_hostVersion', + if (shapeIssues.isNotEmpty) 'variableIssues': shapeIssues, + }, + capabilities: capabilities, + ); + } + } + + Future _refreshContext(_ScriptContextState context) async { + if (context.closed) { + return; + } + final running = context.refreshing; + if (running != null) { + context.refreshAgain = true; + await running; + return; + } + final completed = Completer(); + context.refreshing = completed.future; + try { + do { + context.refreshAgain = false; + final before = context.view; + // A turn may stop waiting for this view when a resource mutation is + // waiting for that turn to finish. The admitted carrier reads still + // belong to the engine drain; host acknowledgements below do not. + final after = await engine._trackOperation(_buildView(context)); + if (context.closed) { + return; + } + if (after == null) { + return; + } + // Public snapshot reads do not move this published-delta baseline. + context.view = after; + if (before == null) { + continue; + } + final oldById = {for (final message in before.messages) message.ref.nodeId: message}; + final newById = {for (final message in after.messages) message.ref.nodeId: message}; + final changed = [ + for (final message in after.messages) + if (!_sameScriptMessage(oldById[message.ref.nodeId], message)) message, + ]; + final removed = [ + for (final message in before.messages) + if (!newById.containsKey(message.ref.nodeId)) message.ref, + ]; + final index = {}; + for (var i = 0; i < max(before.messages.length, after.messages.length); i++) { + final old = i < before.messages.length ? before.messages[i].ref : null; + final next = i < after.messages.length ? after.messages[i].ref : null; + if (old != next) { + index[i] = next; + } + } + final oldVariables = { + for (final value in before.variableSnapshots) _targetKey(value.resolvedTarget): value.version, + }; + final variableChanges = [ + for (final value in after.variableSnapshots) + if (oldVariables[_targetKey(value.resolvedTarget)] != value.version) value, + ]; + final resourcesChanged = !_stateJsonEqual(before.resourceRefs, after.resourceRefs); + if (changed.isEmpty && + removed.isEmpty && + index.isEmpty && + variableChanges.isEmpty && + !resourcesChanged && + before.pathRevision == after.pathRevision) { + continue; + } + final delta = TavernScriptDelta( + baseRevision: before.pathRevision, + newRevision: after.pathRevision, + upsertMessages: changed, + removedRefs: removed, + indexChanges: index, + variableSnapshots: variableChanges, + resourceRefs: resourcesChanged ? after.resourceRefs : const {}, + ); + final request = _dispatch(context, kind: TavernScriptDispatchKind.cache, delta: delta); + final issues = await _deliver(context, request, committed: true); + if (issues.isNotEmpty) { + throw _scriptError( + TavernScriptErrorCode.hookFailure, + 'cache', + 'The committed cache delivery was not acknowledged.', + contextId: context.value.id, + committed: true, + issues: issues, + ); + } + } while (context.refreshAgain && !context.closed); + } catch (error, stack) { + final TavernScriptException failure; + if (error is TavernScriptException && error.committed && error.issues.isNotEmpty) { + failure = error; + } else { + final classified = _scriptFailure(error); + final request = _dispatch(context, kind: TavernScriptDispatchKind.cache); + final issue = TavernScriptIssue( + code: classified.code.name, + message: 'The committed cache view could not be read.', + committed: true, + contextId: context.value.id, + eventId: request.eventId, + stage: 'cache', + ); + _observe(request, 'failed', true, issues: [issue]); + failure = _scriptError( + classified.code, + 'cache', + issue.message, + contextId: context.value.id, + committed: true, + issues: [issue], + ); + } + completed.completeError(failure, stack); + // This completion is also awaited by reentrant refresh callers. + completed.future.ignore(); + Error.throwWithStackTrace(failure, stack); + } finally { + context.refreshing = null; + if (!completed.isCompleted) { + completed.complete(); + } + } + } + + TavernScriptDispatch _dispatch( + _ScriptContextState context, { + required TavernScriptDispatchKind kind, + String? name, + TavernScriptMessageRef? ref, + String? generationId, + List args = const [], + TavernScriptDelta? delta, + String? eventId, + ({int? messageId, int? swipeId, String? pathRevision})? location, + }) { + context.seq++; + final view = context.view; + final message = view?.messages.where((item) => item.ref == ref).firstOrNull; + if (view != null) { + context.view = TavernScriptSnapshot( + context: view.context, + engineEpoch: epoch, + lastSeq: context.seq, + pathRevision: view.pathRevision, + messages: view.messages, + variableSnapshots: view.variableSnapshots, + presetId: view.presetId, + personaId: view.personaId, + memberCharacterIds: view.memberCharacterIds, + resourceRefs: view.resourceRefs, + capabilities: capabilities, + ); + } + final captured = + location ?? (messageId: message?.messageId, swipeId: message?.swipeId, pathRevision: view?.pathRevision); + return TavernScriptDispatch( + kind: kind, + eventId: eventId ?? 'event-${_newStateId()}', + contextId: context.value.id, + chatId: context.value.chatId, + seq: context.seq, + namespace: kind == TavernScriptDispatchKind.lifecycle ? 'tavern' : null, + name: name, + generationId: generationId, + nodeRef: ref, + messageId: captured.messageId, + swipeId: captured.swipeId, + pathRevision: captured.pathRevision, + args: args, + delta: delta, + ); + } + + Future> _deliver( + _ScriptContextState context, + TavernScriptDispatch request, { + required bool committed, + ModelCancellation? cancellation, + _ScriptCancellation? pendingToken, + }) { + return _ownDelivery( + () => _deliverCaptured( + context, + request, + committed: committed, + cancellation: cancellation, + pendingToken: pendingToken, + ), + ); + } + + Future _ownDelivery(Future Function() action) { + final completed = Completer(); + _deliveries.add(completed.future); + // Ownership precedes the App call, including a host which synchronously + // begins close() from inside dispatch/refresh before returning its Future. + final result = Future.sync(action); + void release() { + _deliveries.remove(completed.future); + completed.complete(); + } + + unawaited( + result.then( + (_) { + release(); + }, + onError: (Object _, StackTrace _) { + release(); + }, + ), + ); + return result; + } + + Future> _deliverCaptured( + _ScriptContextState context, + TavernScriptDispatch request, { + required bool committed, + ModelCancellation? cancellation, + _ScriptCancellation? pendingToken, + }) async { + final host = _host; + _observe(request, 'published', committed); + if (host == null && pendingToken?.isCancelled != true && !context.closed && !_closed) { + context.pending.remove(request.eventId); + return []; + } + final hostVersion = _hostVersion; + final token = pendingToken ?? _ScriptCancellation(); + context.pending[request.eventId] = token; + if (context.closed || _closed) { + token.cancel(); + } + if (cancellation != null) { + if (cancellation.isCancelled) { + token.cancel(); + } + unawaited(cancellation.whenCancelled.then((_) => token.cancel())); + } + List failure(String code, String message, {bool cancelled = false}) => [ + TavernScriptIssue( + code: code, + message: message, + committed: committed, + contextId: context.value.id, + eventId: request.eventId, + generationId: request.generationId, + nodeRef: request.nodeRef, + stage: request.kind.name, + cancelled: cancelled, + ), + ]; + late final List issues; + try { + if (token.isCancelled) { + issues = failure(token.reason, 'Host delivery was cancelled.', cancelled: true); + } else { + final ack = await Future.any([ + Future.sync(() => host!.dispatch(request, token)), + token.whenCancelled.then((_) => null), + ]); + if (ack == null || context.closed || hostVersion != _hostVersion) { + issues = failure(token.reason, 'Host delivery ended before acknowledgement.', cancelled: token.isCancelled); + } else if (ack.eventId != request.eventId) { + issues = failure('hookFailure', 'Host acknowledged a different event identity.'); + } else { + issues = [ + ...ack.issues, + if (ack.status != TavernScriptAckStatus.completed) + ...failure( + ack.status == TavernScriptAckStatus.cancelled ? 'cancelled' : 'hookFailure', + 'Host handler did not complete.', + cancelled: ack.status == TavernScriptAckStatus.cancelled, + ), + ]; + } + } + } catch (error) { + issues = failure('hookFailure', 'Host callback failed (${error.runtimeType}).'); + } finally { + context.pending.remove(request.eventId); + } + _observe(request, issues.isEmpty ? 'acknowledged' : 'failed', committed, issues: issues); + return issues; + } + + void _observe( + TavernScriptDispatch request, + String phase, + bool committed, { + List issues = const [], + }) { + if (!_events.isClosed) { + _events.add(TavernScriptEvent(dispatch: request, phase: phase, committed: committed, issues: issues)); + } + } + + Future> _lifecycle( + _ScriptContextState context, + _ScriptLifecycle event, { + String? generationId, + ModelCancellation? cancellation, + bool committed = true, + bool serialized = false, + }) { + final eventId = 'event-${_newStateId()}'; + final message = context.view?.messages.where((item) => item.ref == event.nodeRef).firstOrNull; + final location = + event.location ?? + (messageId: message?.messageId, swipeId: message?.swipeId, pathRevision: context.view?.pathRevision); + final token = _ScriptCancellation(); + context.pending[eventId] = token; + if (cancellation != null) { + if (cancellation.isCancelled) { + token.cancel(); + } + unawaited(cancellation.whenCancelled.then((_) => token.cancel())); + } + Future> deliver() { + // Register cancellation ownership immediately, but assign seq at actual + // publication. Main lifecycle can bypass a pending derived notification + // without a later publication moving the context sequence backwards. + final request = _dispatch( + context, + kind: TavernScriptDispatchKind.lifecycle, + name: event.name, + ref: event.nodeRef, + generationId: generationId, + args: event.args, + eventId: eventId, + location: location, + ); + return _deliver(context, request, committed: committed, pendingToken: token); + } + + // A new generation must not queue behind an ENDED handler that is itself + // awaiting that generation. Only detached mutation notifications share + // the ordinary per-context notification tail. + if (!serialized) { + return deliver(); + } + final result = context.lifecycle.then((_) { + return deliver(); + }); + context.lifecycle = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + void _scheduleLifecycle( + _ScriptContextState context, + _ScriptLifecycle event, { + String? generationId, + bool mainLifecycle = false, + ModelCancellation? cancellation, + }) { + // The storage setter must not await a lifecycle queued behind its caller. + // Failures are observed by _deliver; cancellation/detach releases its wait. + unawaited( + _lifecycle( + context, + event, + generationId: generationId, + serialized: !mainLifecycle, + cancellation: cancellation, + ).then( + (_) {}, + onError: (Object error, StackTrace _) { + final request = _dispatch( + context, + kind: TavernScriptDispatchKind.lifecycle, + name: event.name, + ref: event.nodeRef, + generationId: generationId, + ); + _observe( + request, + 'failed', + true, + issues: [ + TavernScriptIssue( + code: 'hookFailure', + message: 'Lifecycle scheduling failed (${error.runtimeType}).', + committed: true, + contextId: context.value.id, + ), + ], + ); + }, + ), + ); + } + + Future _publishMutation( + _ScriptContextState origin, + TavernScriptMutation mutation, { + List<_ScriptLifecycle> events = const [], + }) async { + final related = contexts.values.where((item) => !item.closed && item.value.chatId == origin.value.chatId).toList(); + for (final context in related) { + await _refreshContext(context); + } + for (final context in related) { + for (final event in events) { + _scheduleLifecycle(context, event); + } + } + if (mutation.refresh == TavernScriptRefresh.none || _host == null || origin.closed) { + return; + } + _scheduleRefresh(origin, mutation); + } + + void _scheduleRefresh(_ScriptContextState origin, TavernScriptMutation mutation) { + final host = _host; + if (host == null || origin.closed) { + return; + } + final hostVersion = _hostVersion; + final token = _ScriptCancellation(); + final request = _dispatch(origin, kind: TavernScriptDispatchKind.cache); + origin.pending[request.eventId] = token; + unawaited( + _ownDelivery(() async { + try { + final completed = await Future.any([ + Future.sync( + () => host.refresh( + TavernScriptRefreshRequest( + requestId: request.eventId, + contextId: origin.value.id, + mode: mutation.refresh, + pathRevision: mutation.pathRevision, + refs: [...mutation.affectedRefs, ...mutation.removedRefs], + reason: 'messages', + ), + ), + ).then((_) => true), + token.whenCancelled.then((_) => false), + ]); + if (!completed || hostVersion != _hostVersion || !identical(host, _host) || origin.closed) { + _observe( + request, + 'failed', + true, + issues: [ + TavernScriptIssue( + code: token.reason, + message: 'Canvas refresh was cancelled.', + committed: true, + cancelled: true, + contextId: origin.value.id, + eventId: request.eventId, + stage: 'refresh', + ), + ], + ); + } else if (mutation.refresh == TavernScriptRefresh.all && !origin.closed) { + _scheduleLifecycle(origin, _ScriptLifecycle(name: 'CHAT_CHANGED', args: [origin.value.chatId])); + } + } catch (error) { + _observe( + request, + 'failed', + true, + issues: [ + TavernScriptIssue( + code: 'hookFailure', + message: 'Canvas refresh failed (${error.runtimeType}).', + committed: true, + contextId: origin.value.id, + eventId: request.eventId, + stage: 'refresh', + ), + ], + ); + } finally { + origin.pending.remove(request.eventId); + } + }), + ); + } + + void nativeChanged(String chatId) { + if (_closed || !contexts.values.any((context) => !context.closed && context.value.chatId == chatId)) { + return; + } + if (!_nativeDirty.add(chatId)) { + return; + } + unawaited( + Future(() async { + _nativeDirty.remove(chatId); + for (final context in contexts.values.where((item) => !item.closed && item.value.chatId == chatId).toList()) { + try { + // Streaming checkpoints change only the draft body, not the durable + // tree revision. They already have native token events; do not copy + // the entire stored path into every iframe for each checkpoint. + if (context.view?.pathRevision != _pathRevision(chatId)) { + await _refreshContext(context); + } + } catch (_) { + /* _deliver publishes structured issues. */ + } + } + }), + ); + } + + void resourcesChanged() { + if (_closed || contexts.isEmpty) { + return; + } + const key = '\u0000resources'; + if (!_nativeDirty.add(key)) { + return; + } + unawaited( + Future(() async { + _nativeDirty.remove(key); + for (final context in contexts.values.toList()) { + if (context.closed) { + continue; + } + try { + await _refreshContext(context); + } catch (_) { + /* Cache errors are observable separately. */ + } + } + }), + ); + } + + void cancelChat(String chatId) { + // Ownership survives removal of _turns, including queued actor ENDED + // notifications while the outer group still owns the chat. + for (final context in contexts.values) { + if (context.value.chatId != chatId) { + continue; + } + for (final token in context.pending.values) { + token.cancel(); + } + } + } + + Future groupSettled(String chatId, ModelCancellation cancellation) async { + cancellation.check(); + for (final context in contexts.values.where((item) => !item.closed && item.value.chatId == chatId).toList()) { + cancellation.check(); + try { + await cancellation.wait(_refreshContext(context)); + } catch (_) { + // Ordinary cache failures remain visible in script events. An outer + // group cancellation must release the group-owned logical barrier. + cancellation.check(); + } + cancellation.check(); + } + } + + Future nativeMutation( + String chatId, + String name, { + String? nodeId, + String? generationId, + ModelCancellation? cancellation, + List args = const [], + ({int? messageId, int? swipeId, String? pathRevision})? location, + }) async { + for (final context in contexts.values.where((item) => !item.closed && item.value.chatId == chatId).toList()) { + try { + await _refreshContext(context); + } catch (_) { + /* Cache issues remain observable. */ + } + if (!context.closed) { + _scheduleLifecycle( + context, + _ScriptLifecycle( + name: name, + nodeRef: nodeId == null ? null : TavernScriptMessageRef(chatId: chatId, nodeId: nodeId), + location: location, + args: args, + ), + generationId: generationId, + mainLifecycle: name == 'MESSAGE_SENT', + cancellation: cancellation, + ); + } + } + } + + _ScriptSent? captureSent(String chatId, String nodeId) { + if (!contexts.values.any((context) => !context.closed && context.value.chatId == chatId)) { + return null; + } + final path = engine._historyTo(chatId, engine._store.chat(chatId).currentLeafId); + return _ScriptSent( + TavernScriptMessageRef(chatId: chatId, nodeId: nodeId), + path.indexWhere((node) => node.message.nodeId == nodeId), + _pathRevision(chatId), + ); + } + + void publishSent(_ScriptSent? sent, {String? generationId, ModelCancellation? cancellation}) { + if (sent == null || sent.published) { + return; + } + sent.published = true; + // User SQL has committed, but no handler runs while the request is still + // being assembled. A failed preparation still publishes this commit fact + // once, with its captured floor even if the old rollback removed the node. + unawaited( + nativeMutation( + sent.ref.chatId, + 'MESSAGE_SENT', + nodeId: sent.ref.nodeId, + generationId: generationId, + cancellation: cancellation, + location: (messageId: sent.messageId, swipeId: null, pathRevision: sent.pathRevision), + ), + ); + } + + Future beforeTurn(_Turn turn) async { + turn.scriptWritesAllowed = true; + try { + final active = contexts.values.where((item) => !item.closed && item.value.chatId == turn.chat.id).toList(); + for (final context in active) { + turn.cancellation.check(); + await turn.cancellation.wait(_refreshContext(context)); + turn.cancellation.check(); + } + for (final name in const ['GENERATION_STARTED', 'GENERATION_AFTER_COMMANDS']) { + for (final context in active) { + if (context.closed) { + continue; + } + turn.hookIssues.addAll( + await _lifecycle( + context, + _ScriptLifecycle(name: name), + generationId: turn.generationId, + cancellation: turn.cancellation, + committed: false, + ), + ); + turn.cancellation.check(); + } + } + await drainWrites(turn.chat.id, turn.cancellation); + } catch (error) { + if (error is TavernScriptException) { + turn.hookIssues.addAll(error.issues); + } + turn.cancellation.check(); + rethrow; + } finally { + turn.scriptWritesAllowed = false; + } + } + + Future afterMessage(_Turn turn) async { + final reply = turn.lastCommitted; + if (reply == null || turn.kind == _TurnKind.impersonate) { + return; + } + try { + for (final context + in contexts.values.where((item) => !item.closed && item.value.chatId == turn.chat.id).toList()) { + try { + turn.cancellation.check(); + await turn.cancellation.wait(_refreshContext(context)); + turn.cancellation.check(); + if (!context.closed) { + turn.hookIssues.addAll( + await _lifecycle( + context, + _ScriptLifecycle( + name: 'MESSAGE_RECEIVED', + nodeRef: TavernScriptMessageRef(chatId: turn.chat.id, nodeId: reply.nodeId), + ), + generationId: turn.generationId, + cancellation: turn.cancellation, + ), + ); + } + } catch (error) { + final cancelled = turn.cancellation.isCancelled; + turn.hookIssues.add( + TavernScriptIssue( + code: cancelled ? 'cancelled' : 'hookFailure', + committed: true, + message: 'Committed message hook failed (${error.runtimeType}).', + contextId: context.value.id, + generationId: turn.generationId, + nodeRef: TavernScriptMessageRef(chatId: turn.chat.id, nodeId: reply.nodeId), + cancelled: cancelled, + ), + ); + if (cancelled) { + break; + } + } + } + // Direct handler writes are flushed by the Ack. The native short queue + // also drains before the next actor reads state. Derived lifecycle is not + // part of this barrier and may still be queued in the App. + if (!turn.cancellation.isCancelled) { + await drainWrites(turn.chat.id, turn.cancellation); + } + final current = engine._store.node(turn.chat.id, reply.nodeId); + turn.lastCommitted = current.message; + turn.state = engine._stateAt(turn.chat.id, reply.nodeId); + turn.variables = _runtimeCopy(_map(turn.state['variables'])); + } catch (error) { + turn.hookIssues.add( + TavernScriptIssue( + code: turn.cancellation.isCancelled ? 'cancelled' : 'hookFailure', + message: 'Post-commit anchor changed (${error.runtimeType}).', + committed: true, + generationId: turn.generationId, + cancelled: turn.cancellation.isCancelled, + ), + ); + } + } + + void ended(_Turn turn) { + final terminal = turn.terminalEvent; + final success = terminal is TavernTurnDone ? terminal : null; + final failure = terminal is TavernTurnFailed ? terminal : null; + final cancelled = turn.cancellation.isCancelled || failure?.kind == TavernFailure.cancelled; + final message = success?.message ?? failure?.partial; + final ref = message == null || turn.kind == _TurnKind.impersonate + ? null + : TavernScriptMessageRef(chatId: turn.chat.id, nodeId: message.nodeId); + final payload = { + 'status': success != null + ? 'completed' + : failure != null + ? (failure.kind == TavernFailure.cancelled ? 'cancelled' : 'failed') + : turn.inspection.status.name, + 'generationType': turn.generationType, + 'cancelled': cancelled, + // This flag means successful model settlement, not that a failed turn + // could not retain a partial or an earlier user/secret-state commit. + 'committed': turn.modelCommitted, + 'finishReason': success?.finishReason ?? turn.finishReason, + 'modelFailure': failure?.kind.name, + 'replyRef': success != null && ref != null ? {'chatId': ref.chatId, 'nodeId': ref.nodeId} : null, + 'partialRef': failure?.partial != null && ref != null ? {'chatId': ref.chatId, 'nodeId': ref.nodeId} : null, + 'hookIssues': [ + for (final issue in success?.hookIssues ?? failure?.hookIssues ?? turn.hookIssues) _scriptIssueJson(issue), + ], + }; + for (final context in contexts.values.where((item) => !item.closed && item.value.chatId == turn.chat.id).toList()) { + if (cancelled) { + _scheduleLifecycle( + context, + _ScriptLifecycle(name: 'GENERATION_STOPPED', nodeRef: ref, args: [payload]), + generationId: turn.generationId, + mainLifecycle: true, + ); + } + _scheduleLifecycle( + context, + _ScriptLifecycle(name: 'GENERATION_ENDED', nodeRef: ref, args: [payload]), + generationId: turn.generationId, + mainLifecycle: true, + ); + } + } + + void close() { + _closed = true; + for (final context in contexts.values) { + context.closed = true; + for (final token in context.pending.values) { + token.cancel('hostUnavailable'); + } + for (final token in context.callbacks) { + token.cancel(); + } + } + _host = null; + // Cancellation resolves our races even when a host ignores its token. + // Keep the observation stream open until those outcomes are published; + // a paused observer must still never hold engine.close(). + unawaited(() async { + await Future.wait([..._deliveries, for (final context in contexts.values) context.lifecycle]); + unawaited(_events.close()); + }()); + } +} + +TavernScriptException _scriptError( + TavernScriptErrorCode code, + String operation, + String message, { + String? contextId, + String? nodeId, + String? fieldPath, + bool committed = false, + List issues = const [], + TavernStateEditResult? stateEdit, +}) => TavernScriptException( + code: code, + operation: operation, + message: message, + contextId: contextId, + nodeId: nodeId, + fieldPath: fieldPath, + committed: committed, + issues: issues, + stateEdit: stateEdit, +); + +Map _scriptIssueJson(TavernScriptIssue issue) => { + 'code': issue.code, + 'message': issue.message, + 'committed': issue.committed, + 'contextId': ?issue.contextId, + 'eventId': ?issue.eventId, + 'generationId': ?issue.generationId, + if (issue.nodeRef case final ref?) 'nodeRef': {'chatId': ref.chatId, 'nodeId': ref.nodeId}, + if (issue.sourceSpan case final span?) + 'sourceSpan': { + 'sourceId': ?span.sourceId, + 'start': span.start, + 'end': span.end, + 'line': span.line, + 'column': span.column, + }, + 'stage': ?issue.stage, + 'cancelled': issue.cancelled, + 'details': issue.details, +}; + +Object? _captureScriptJson(Object? value, [Set? visited]) { + if (value == null || value is String || value is bool) { + return value; + } + if (value is num && value.isFinite) { + return value; + } + final seen = visited ?? HashSet.identity(); + if (value is Map || value is List) { + if (!seen.add(value)) { + throw _scriptError(TavernScriptErrorCode.invalidJson, 'capture', 'JSON contains a cycle.'); + } + try { + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + if (entry.key is! String) { + throw _scriptError(TavernScriptErrorCode.invalidJson, 'capture', 'JSON keys must be strings.'); + } + result[entry.key as String] = _captureScriptJson(entry.value, seen); + } + return result; + } + return [for (final item in value as List) _captureScriptJson(item, seen)]; + } finally { + seen.remove(value); + } + } + throw _scriptError(TavernScriptErrorCode.invalidJson, 'capture', 'Expected finite standard JSON.'); +} + +Map _captureScriptObject(Object? value) { + final result = _captureScriptJson(value); + if (result is! Map) { + throw _scriptError(TavernScriptErrorCode.shapeMismatch, 'variables', 'Expected a JSON object.'); + } + return result; +} + +bool _sameScriptMessage(TavernScriptMessage? a, TavernScriptMessage b) => + a != null && + a.messageId == b.messageId && + a.ref == b.ref && + a.name == b.name && + a.role == b.role && + a.nativeRole == b.nativeRole && + a.message == b.message && + a.hidden == b.hidden && + a.speakerCharacterId == b.speakerCharacterId && + a.swipeId == b.swipeId && + a.hasData == b.hasData && + _stateJsonEqual(a.rawData, b.rawData) && + _stateJsonEqual(a.extra, b.extra) && + a.swipes.length == b.swipes.length && + List.generate(a.swipes.length, (i) => i).every((i) { + final x = a.swipes[i], y = b.swipes[i]; + return x.ref == y.ref && + x.text == y.text && + x.hasData == y.hasData && + _stateJsonEqual(x.rawData, y.rawData) && + _stateJsonEqual(x.info, y.info); + }); diff --git a/packages/dsh_tavern/lib/src/engine_scripts_messages.dart b/packages/dsh_tavern/lib/src/engine_scripts_messages.dart new file mode 100644 index 0000000000..24ac966090 --- /dev/null +++ b/packages/dsh_tavern/lib/src/engine_scripts_messages.dart @@ -0,0 +1,1447 @@ +part of 'engine.dart'; + +TavernScriptRole _scriptRole(TavernRole role) => switch (role) { + TavernRole.user => TavernScriptRole.user, + TavernRole.character => TavernScriptRole.assistant, + TavernRole.narrator || TavernRole.system => TavernScriptRole.system, +}; + +TavernRole _scriptNativeRole(TavernScriptRole role) => switch (role) { + TavernScriptRole.user => TavernRole.user, + TavernScriptRole.assistant => TavernRole.character, + TavernScriptRole.system => TavernRole.system, +}; + +TavernScriptMessageRef _scriptRef(String chatId, String nodeId) => + TavernScriptMessageRef(chatId: chatId, nodeId: nodeId); + +TavernScriptException _messageScriptError( + String contextId, + String operation, + TavernScriptErrorCode code, + String message, { + String? nodeId, + String? fieldPath, +}) => TavernScriptException( + code: code, + operation: operation, + message: message, + contextId: contextId, + nodeId: nodeId, + fieldPath: fieldPath, +); + +Map _scriptUserExtra(Map extra) => _copy(extra)..remove('dshgo'); + +TavernScriptShapeIssue? _scriptMessageShape(Map state) { + if (!state.containsKey('messageVariables') || state['messageVariables'] is Map) { + return null; + } + final value = state['messageVariables']; + final kind = value == null + ? 'null' + : value is String + ? 'string' + : value is bool + ? 'bool' + : value is num + ? 'number' + : value is List + ? 'list' + : 'object'; + return TavernScriptShapeIssue( + fieldPath: '/messageVariables', + actualKind: kind, + message: 'Saved message variables are not an object; rawData is preserved.', + ); +} + +Map _scriptSwipeInfo(StoredNode node) { + final index = node.original['swipe_id']; + final infos = node.original['swipe_info']; + final originalIndex = index is num && index.isFinite && index >= 0 && index == index.roundToDouble() + ? index.toInt() + : 0; + final info = infos is List && originalIndex < infos.length && infos[originalIndex] is Map + ? _copy(infos[originalIndex]! as Map) + : {}; + for (final key in const ['send_date', 'gen_started', 'gen_finished', 'extra']) { + info.remove(key); + } + info['send_date'] = node.original.containsKey('send_date') + ? node.original['send_date'] + : node.message.at.toUtc().toIso8601String(); + for (final key in const ['gen_started', 'gen_finished']) { + if (node.original.containsKey(key)) { + info[key] = node.original[key]; + } + } + info['extra'] = _scriptUserExtra(node.extra); + return info; +} + +extension _ScriptMessageOperations on _ScriptCoordinator { + TavernScriptMessage _projectMessage(String chatId, StoredNode node, int index, {required bool includeSwipes}) { + final message = node.message; + final state = engine._stateAt(chatId, message.nodeId); + return TavernScriptMessage( + messageId: index, + ref: _scriptRef(chatId, message.nodeId), + name: message.speaker, + role: _scriptRole(message.role), + nativeRole: message.role, + message: message.text, + hidden: message.hidden, + speakerCharacterId: message.speakerCharacterId, + hasData: state.containsKey('messageVariables'), + rawData: state['messageVariables'], + dataIssue: _scriptMessageShape(state), + extra: _scriptUserExtra(node.extra), + swipeId: message.siblingIndex, + swipes: !includeSwipes + ? const [] + : [for (final sibling in engine._store.children(chatId, message.parentId)) _projectSwipe(chatId, sibling)], + ); + } + + TavernScriptSwipe _projectSwipe(String chatId, StoredNode node) { + final state = engine._stateAt(chatId, node.message.nodeId); + return TavernScriptSwipe( + ref: _scriptRef(chatId, node.message.nodeId), + swipeId: node.message.siblingIndex, + text: node.message.text, + hasData: state.containsKey('messageVariables'), + rawData: state['messageVariables'], + dataIssue: _scriptMessageShape(state), + info: _scriptSwipeInfo(node), + ); + } + + List _projectMessages(_ScriptChatCapture capture, {bool includeSwipes = true}) => [ + for (var index = 0; index < capture.path.length; index++) + _projectMessage(capture.chatId, capture.path[index], index, includeSwipes: includeSwipes), + ]; + + Future _getChatMessages( + String contextId, + TavernMessageRange range, { + TavernScriptRole? role, + TavernHiddenFilter hidden = TavernHiddenFilter.all, + bool includeSwipes = false, + }) => Future.sync(() { + final capture = _captureChat(contextId); + final count = capture.path.length; + final first = range.first == null + ? 0 + : range.first! < 0 + ? count + range.first! + : range.first!; + final last = range.last == null + ? count - 1 + : range.last! < 0 + ? count + range.last! + : range.last!; + final result = []; + for (var index = max(0, first); index < count && index <= last; index++) { + final node = capture.path[index]; + if (role != null && _scriptRole(node.message.role) != role) { + continue; + } + if (hidden == TavernHiddenFilter.hiddenOnly && !node.message.hidden || + hidden == TavernHiddenFilter.visibleOnly && node.message.hidden) { + continue; + } + result.add(_projectMessage(capture.chatId, node, index, includeSwipes: includeSwipes)); + } + return TavernScriptMessagePage(pathRevision: capture.revision, totalPathLength: count, messages: result); + }); + + int _messageIndex(_ScriptChatCapture capture, int index, String operation, {bool allowEnd = false}) { + final length = capture.path.length; + final resolved = index < 0 ? length + index : index; + if (resolved < 0 || resolved > length || !allowEnd && resolved == length) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.range, + 'The message index is outside the captured path.', + ); + } + return resolved; + } + + Map _captureMessageFields(String contextId, Map fields) { + const operation = 'setChatMessages'; + const allowed = { + 'name', + 'role', + 'message', + 'hidden', + 'data', + 'extra', + 'swipeId', + 'swipes', + 'swipesData', + 'swipesInfo', + 'speakerCharacterId', + }; + for (final entry in fields.entries) { + final value = entry.value; + if (!allowed.contains(entry.key)) { + throw _messageScriptError( + contextId, + operation, + TavernScriptErrorCode.invalidArgument, + 'Unknown message field: ${entry.key}', + fieldPath: '/${entry.key}', + ); + } + final valid = switch (entry.key) { + 'name' || 'message' => value is String, + 'role' => value is String && TavernScriptRole.values.any((role) => role.name == value), + 'hidden' => value is bool, + 'data' || 'extra' => value is Map, + 'speakerCharacterId' => value == null || value is String && value.isNotEmpty, + 'swipeId' => value is int && value >= 0, + 'swipes' => value is List && value.isNotEmpty && value.every((item) => item is String), + 'swipesData' || 'swipesInfo' => value is List && value.every((item) => item is Map), + _ => false, + }; + if (!valid) { + throw _messageScriptError( + contextId, + operation, + TavernScriptErrorCode.invalidArgument, + 'Invalid value for message field ${entry.key}.', + fieldPath: '/${entry.key}', + ); + } + } + _checkUserExtra(contextId, operation, fields['extra'], '/extra'); + final infos = fields['swipesInfo']; + if (infos is List) { + for (var index = 0; index < infos.length; index++) { + final info = infos[index]! as Map; + if (info.containsKey('extra')) { + if (info['extra'] is! Map) { + throw _messageScriptError( + contextId, + operation, + TavernScriptErrorCode.invalidArgument, + 'Swipe extra must be an object.', + fieldPath: '/swipesInfo/$index/extra', + ); + } + _checkUserExtra(contextId, operation, info['extra'], '/swipesInfo/$index/extra'); + } + } + } + return fields; + } + + void _checkUserExtra(String contextId, String operation, Object? value, String path) { + if (value is Map && value.containsKey('dshgo')) { + throw _messageScriptError( + contextId, + operation, + TavernScriptErrorCode.reservedField, + 'extra.dshgo is owned by the engine; edit state through data.', + fieldPath: '$path/dshgo', + ); + } + } + + _CapturedMessagePatch _captureMessagePatch(_ScriptChatCapture capture, TavernScriptMessagePatch patch) { + final contextId = capture.context.value.id; + final fields = _captureMessageFields(contextId, patch.fields); + String? target; + if (patch.messageId != null) { + target = capture.path[_messageIndex(capture, patch.messageId!, 'setChatMessages')].message.nodeId; + } + if (patch.ref != null) { + if (patch.ref!.chatId != capture.chatId || target != null && target != patch.ref!.nodeId) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.invalidArgument, + 'Message number and stable reference must identify the same page in this chat.', + ); + } + target = patch.ref!.nodeId; + } + if (target == null) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.invalidArgument, + 'A message number or stable reference is required.', + ); + } + final node = _requireScriptNode(capture, target, 'setChatMessages'); + final family = engine._store.children(capture.chatId, node.message.parentId); + final familyIds = [for (final page in family) page.message.nodeId]; + final targetIndex = familyIds.indexOf(target); + final visible = capture.path.where((page) => page.message.parentId == node.message.parentId).firstOrNull; + final newCount = fields['swipes'] is List ? (fields['swipes']! as List).length : family.length; + final selectedIndex = fields['swipeId'] as int? ?? targetIndex; + if (!fields.containsKey('swipeId') && + (targetIndex >= newCount || visible != null && familyIds.indexOf(visible.message.nodeId) >= newCount)) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.invalidArgument, + 'Removing the target or selected page requires an explicit retained swipeId.', + ); + } + if (selectedIndex >= newCount) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.range, + 'swipeId must identify a retained or newly supplied page.', + ); + } + for (final key in const ['swipesData', 'swipesInfo']) { + if (fields[key] is List && (fields[key]! as List).length != newCount) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.invalidArgument, + '$key must have exactly one object per resulting swipe.', + ); + } + } + void consistent(String field, Object? parallel) { + if (fields.containsKey(field) && !_stateJsonEqual(fields[field], parallel)) { + throw _messageScriptError( + contextId, + 'setChatMessages', + TavernScriptErrorCode.invalidArgument, + 'Conflicting repeated values for $field and its selected swipe.', + fieldPath: '/$field', + ); + } + } + + if (fields['swipes'] is List) { + consistent('message', (fields['swipes']! as List)[selectedIndex]); + } + if (fields['swipesData'] is List) { + consistent('data', (fields['swipesData']! as List)[selectedIndex]); + } + if (fields['swipesInfo'] is List) { + final info = (fields['swipesInfo']! as List)[selectedIndex]! as Map; + if (info.containsKey('extra')) { + consistent('extra', info['extra']); + } + } + return _CapturedMessagePatch( + nodeId: target, + fields: fields, + familyIds: familyIds, + newIds: [for (var index = family.length; index < newCount; index++) engine._store.newNodeId()], + selectedIndex: selectedIndex, + entryState: newCount > family.length ? engine._stateAt(capture.chatId, target) : null, + needsCurrentPath: patch.messageId != null, + structural: newCount != family.length || fields.containsKey('swipeId'), + ); + } + + StoredNode _requireScriptNode(_ScriptChatCapture capture, String nodeId, String operation) { + try { + return engine._store.node(capture.chatId, nodeId); + } on StateError { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.notFound, + 'The stable page no longer exists in this chat.', + nodeId: nodeId, + ); + } + } + + Future> _messageSpeakers( + _ScriptChatCapture capture, + Iterable ids, + String operation, + ) async { + final result = {}; + for (final id in ids.toSet()) { + if (id.isEmpty) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.invalidArgument, + 'speakerCharacterId must be nonempty or null.', + ); + } + final revision = engine._directors._cardRevisions[id] ?? 0; + final record = await engine._record(id); + if (record == null) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.notFound, + 'The explicitly identified speaker does not exist.', + ); + } + if (revision != (engine._directors._cardRevisions[id] ?? 0)) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.revisionConflict, + 'The speaker changed while preparing the message write.', + ); + } + result[id] = _ScriptSpeakerCapture(revision, record.card.name); + } + return result; + } + + void _checkMessageSpeakers( + _ScriptChatCapture capture, + Map speakers, + String operation, + ) { + for (final entry in speakers.entries) { + if (engine._deletingCharacters.contains(entry.key) || + entry.value.revision != (engine._directors._cardRevisions[entry.key] ?? 0)) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.revisionConflict, + 'The captured speaker changed before commit.', + ); + } + } + } + + void _checkMessageFamily(_ScriptChatCapture capture, String nodeId, List expected, String operation) { + final node = _requireScriptNode(capture, nodeId, operation); + final actual = [ + for (final child in engine._store.children(capture.chatId, node.message.parentId)) child.message.nodeId, + ]; + if (!_stateJsonEqual(expected, actual)) { + throw _messageScriptError( + capture.context.value.id, + operation, + TavernScriptErrorCode.revisionConflict, + 'The captured swipe family changed before commit.', + nodeId: nodeId, + ); + } + } + + Future _runMessagePlan( + _ScriptChatCapture capture, + String operation, + TavernScriptWriteOptions options, { + required bool structural, + bool requireSamePath = false, + void Function()? check, + required void Function(_ScriptMessagePlan plan) prepare, + }) async { + late _ScriptMessagePlan plan; + var committed = false; + TavernScriptMutation action() => engine._store.transaction( + () { + _checkChatCapture(capture, expectedRevision: options.expectedPathRevision, requireSamePath: requireSamePath); + check?.call(); + plan = _ScriptMessagePlan(this, capture, operation, options); + prepare(plan); + return plan.commit(); + }, + onCommitted: () { + committed = true; + plan.adoptCommitted(); + }, + ); + try { + final mutation = structural ? await _structureWrite(capture, action) : await _dataWrite(capture, action); + // This Future waits for committed cache delivery, never for the lifecycle + // notifications produced by this write. A RECEIVED handler may await it. + await _publishMutation(capture.context, mutation, events: plan.lifecycle(mutation)); + return mutation; + } catch (error, stack) { + if (!committed) { + rethrow; + } + final failure = _scriptFailure(error); + if (failure.committed) { + rethrow; + } + Error.throwWithStackTrace( + TavernScriptException( + code: failure.code, + operation: operation, + message: failure.message, + contextId: capture.context.value.id, + nodeId: failure.nodeId, + generationId: failure.generationId, + executionId: failure.executionId, + fieldPath: failure.fieldPath, + sourceSpan: failure.sourceSpan, + committed: true, + modelFailure: failure.modelFailure, + issues: failure.issues, + stateEdit: failure.stateEdit, + ), + stack, + ); + } + } + + Future _setChatMessages( + String contextId, + List patches, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => Future.sync(() { + final capture = _captureChat(contextId); + final captured = [for (final patch in patches) _captureMessagePatch(capture, patch)]; + return _setCapturedMessages(capture, captured, options); + }); + + Future _setCapturedMessages( + _ScriptChatCapture capture, + List<_CapturedMessagePatch> patches, + TavernScriptWriteOptions options, + ) async { + final needsData = patches.any( + (patch) => patch.newIds.isNotEmpty || patch.fields.containsKey('data') || patch.fields.containsKey('swipesData'), + ); + final dataContext = needsData ? await _prepareDataContext(capture.chatId) : null; + final speakers = await _messageSpeakers(capture, [ + for (final patch in patches) + if (patch.fields['speakerCharacterId'] is String) patch.fields['speakerCharacterId']! as String, + ], 'setChatMessages'); + return _runMessagePlan( + capture, + 'setChatMessages', + options, + structural: patches.any((patch) => patch.structural), + requireSamePath: patches.any((patch) => patch.needsCurrentPath), + check: () { + _checkMessageSpeakers(capture, speakers, 'setChatMessages'); + for (final patch in patches) { + _requireScriptNode(capture, patch.nodeId, 'setChatMessages'); + if (const ['swipes', 'swipesData', 'swipesInfo', 'swipeId'].any(patch.fields.containsKey)) { + _checkMessageFamily(capture, patch.nodeId, patch.familyIds, 'setChatMessages'); + } + } + }, + prepare: (plan) { + if (dataContext != null) { + _checkDataContext(dataContext); + } + for (final patch in patches) { + plan.applyPatch(patch, dataContext); + } + }, + ); + } + + Future _createChatMessages( + String contextId, + List messages, { + int? insertBefore, + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => Future.sync(() { + final capture = _captureChat(contextId); + final insertion = insertBefore == null + ? capture.path.length + : _messageIndex(capture, insertBefore, 'createChatMessages', allowEnd: true); + final drafts = <_CapturedMessageDraft>[]; + for (final message in messages) { + final extra = message.extra; + _checkUserExtra(contextId, 'createChatMessages', extra, '/extra'); + final speaker = + message.speakerCharacterId ?? + (message.role == TavernScriptRole.assistant ? capture.context.value.characterId : null); + if (speaker == '') { + throw _messageScriptError( + contextId, + 'createChatMessages', + TavernScriptErrorCode.invalidArgument, + 'speakerCharacterId must be nonempty or omitted.', + ); + } + drafts.add( + _CapturedMessageDraft( + id: engine._store.newNodeId(), + role: message.role, + text: message.message, + name: message.name, + hidden: message.hidden, + speakerCharacterId: speaker, + data: message.data, + extra: extra, + ), + ); + } + return _createCapturedMessages(capture, drafts, insertion, options); + }); + + Future _createCapturedMessages( + _ScriptChatCapture capture, + List<_CapturedMessageDraft> drafts, + int insertion, + TavernScriptWriteOptions options, + ) async { + final dataContext = drafts.isEmpty ? null : await _prepareDataContext(capture.chatId); + final speakers = await _messageSpeakers(capture, [ + for (final draft in drafts) ?draft.speakerCharacterId, + ], 'createChatMessages'); + return _runMessagePlan( + capture, + 'createChatMessages', + options, + structural: drafts.isNotEmpty, + requireSamePath: true, + check: () => _checkMessageSpeakers(capture, speakers, 'createChatMessages'), + prepare: (plan) { + if (drafts.isEmpty) { + return; + } + final context = dataContext!; + _checkDataContext(context); + final oldPath = List.of(plan.path); + final before = insertion < oldPath.length ? oldPath[insertion] : null; + final oldFamily = before == null ? [] : plan.children(plan.links[before]!.parentId, physical: true); + String? parent = insertion == 0 ? null : oldPath[insertion - 1]; + var baseline = parent == null ? _initialMessageState(context) : _copy(plan.page(parent).state); + final chat = engine._store.chat(capture.chatId); + final settings = _personaSettings( + engine._effectiveSettings(chat, engine._chatCodec.metadataForNode(engine._store, capture.chatId)), + engine._personas.resolve(personaId: chat.personaId), + ); + final userName = settings['userName'] is String ? settings['userName']! as String : 'User'; + for (final draft in drafts) { + final name = + draft.name ?? + switch (draft.role) { + TavernScriptRole.user => userName, + TavernScriptRole.assistant => speakers[draft.speakerCharacterId]?.name ?? 'Assistant', + TavernScriptRole.system => 'System', + }; + final value = _ScriptWorkingPage( + id: draft.id, + created: true, + role: _scriptNativeRole(draft.role), + name: name, + text: draft.text, + hidden: draft.hidden, + speakerCharacterId: draft.speakerCharacterId, + state: _copy(baseline), + extra: _copy(draft.extra), + original: {}, + ); + plan.add(value, parent); + if (draft.data != null) { + plan.setData(value, draft.data!, context); + } + baseline = _copy(value.state); + parent = value.id; + } + for (final id in oldFamily) { + plan.reparent(id, parent); + } + plan.path = [...oldPath.take(insertion), for (final draft in drafts) draft.id, ...oldPath.skip(insertion)]; + // Raw CRUD creates cache data; it is not a send/receive operation. + }, + ); + } + + Future _deleteChatMessages( + String contextId, + List messageIds, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => Future.sync(() { + final capture = _captureChat(contextId); + final targets = { + for (final index in messageIds) capture.path[_messageIndex(capture, index, 'deleteChatMessages')].message.nodeId, + }; + return _runMessagePlan( + capture, + 'deleteChatMessages', + options, + structural: targets.isNotEmpty, + requireSamePath: true, + prepare: (plan) => plan.deleteFamilies(targets), + ); + }); + + Future _rotateChatMessages( + String contextId, + int begin, + int middle, + int end, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => Future.sync(() { + final capture = _captureChat(contextId); + final first = _messageIndex(capture, begin, 'rotateChatMessages', allowEnd: true); + final split = _messageIndex(capture, middle, 'rotateChatMessages', allowEnd: true); + final last = _messageIndex(capture, end, 'rotateChatMessages', allowEnd: true); + if (first > split || split > last) { + throw _messageScriptError( + contextId, + 'rotateChatMessages', + TavernScriptErrorCode.range, + 'Rotation requires begin <= middle <= end within the captured path.', + ); + } + final ids = [for (final node in capture.path) node.message.nodeId]; + final desired = [...ids.take(first), ...ids.sublist(split, last), ...ids.sublist(first, split), ...ids.skip(last)]; + return _runMessagePlan( + capture, + 'rotateChatMessages', + options, + structural: first != split && split != last, + requireSamePath: true, + prepare: (plan) { + if (!_stateJsonEqual(plan.path, desired)) { + plan.reorder(desired); + } + }, + ); + }); + + Future _selectSwipe( + String contextId, + TavernScriptMessageRef message, + int swipeId, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => Future.sync(() { + final capture = _captureChat(contextId); + if (message.chatId != capture.chatId) { + throw _messageScriptError( + contextId, + 'selectSwipe', + TavernScriptErrorCode.invalidArgument, + 'The stable message reference belongs to another chat.', + ); + } + final node = _requireScriptNode(capture, message.nodeId, 'selectSwipe'); + final family = [ + for (final sibling in engine._store.children(capture.chatId, node.message.parentId)) sibling.message.nodeId, + ]; + if (swipeId < 0 || swipeId >= family.length) { + throw _messageScriptError( + contextId, + 'selectSwipe', + TavernScriptErrorCode.range, + 'swipeId is outside the captured page family.', + ); + } + final selected = family[swipeId]; + return _runMessagePlan( + capture, + 'selectSwipe', + options, + structural: true, + check: () => _checkMessageFamily(capture, message.nodeId, family, 'selectSwipe'), + prepare: (plan) => plan.select(selected), + ); + }); +} + +class _ScriptSpeakerCapture { + _ScriptSpeakerCapture(this.revision, this.name); + final int revision; + final String name; +} + +class _CapturedMessageDraft { + _CapturedMessageDraft({ + required this.id, + required this.role, + required this.text, + required this.hidden, + required this.extra, + this.name, + this.speakerCharacterId, + this.data, + }); + final String id; + final TavernScriptRole role; + final String text; + final String? name; + final bool hidden; + final String? speakerCharacterId; + final Map? data; + final Map extra; +} + +class _CapturedMessagePatch { + _CapturedMessagePatch({ + required this.nodeId, + required this.fields, + required this.familyIds, + required this.newIds, + required this.selectedIndex, + required this.needsCurrentPath, + required this.structural, + this.entryState, + }); + final String nodeId; + final Map fields; + final List familyIds; + final List newIds; + final int selectedIndex; + final bool needsCurrentPath; + final bool structural; + final Map? entryState; +} + +class _ScriptPlannedLink { + _ScriptPlannedLink(this.parentId, this.selectedChildId, this.sequence, this.deleted); + String? parentId; + String? selectedChildId; + final int sequence; + final bool deleted; +} + +class _ScriptWorkingPage { + _ScriptWorkingPage({ + required this.id, + required this.created, + required this.role, + required this.name, + required this.text, + required this.hidden, + required this.state, + required this.extra, + required this.original, + this.speakerCharacterId, + }); + final String id; + final bool created; + TavernRole role; + String name; + String text; + bool hidden; + String? speakerCharacterId; + Map state; + Map extra; + Map original; + final dirty = {}; +} + +/// A detached batch plan. Field and DSH preparation finish before the first +/// SQL write; later patches see earlier planned changes but retain their +/// originally captured target ids. Only edited pages load full state. +class _ScriptMessagePlan { + _ScriptMessagePlan(this.owner, this.capture, this.operation, this.options) + : oldRevision = owner._pathRevision(capture.chatId), + beforePath = owner.engine._historyTo(capture.chatId, owner.engine._store.chat(capture.chatId).currentLeafId), + originalLinks = owner.engine._store.treeLinks(capture.chatId) { + links.addAll({ + for (final entry in originalLinks.entries) + entry.key: _ScriptPlannedLink( + entry.value.parentId, + entry.value.selectedChildId, + entry.value.sequence, + entry.value.deleted, + ), + }); + path = [for (final node in beforePath) node.message.nodeId]; + sequence = originalLinks.values.fold(0, (value, link) => max(value, link.sequence)); + } + final _ScriptCoordinator owner; + final _ScriptChatCapture capture; + final String operation; + final TavernScriptWriteOptions options; + final String oldRevision; + final List beforePath; + final Map originalLinks; + final links = {}; + final pages = {}; + final removed = {}; + final affected = {}; + final edited = {}; + final updated = {}; + final prepared = <({String id, _ScriptPreparedData value})>[]; + final swiped = <({String? before, String after})>[]; + late List path; + late int sequence; + bool changed = false; + bool clearCachedInspection = false; + String get chatId => capture.chatId; + TavernEngine get engine => owner.engine; + + Never fail(TavernScriptErrorCode code, String text, {String? nodeId}) => + throw _messageScriptError(capture.context.value.id, operation, code, text, nodeId: nodeId); + + _ScriptWorkingPage page(String id) { + final link = links[id]; + if (link == null || link.deleted || removed.contains(id)) { + fail(TavernScriptErrorCode.notFound, 'A captured page was removed earlier in this batch.', nodeId: id); + } + return pages.putIfAbsent(id, () { + final node = engine._store.node(chatId, id); + return _ScriptWorkingPage( + id: id, + created: false, + role: node.message.role, + name: node.message.speaker, + text: node.message.text, + hidden: node.message.hidden, + speakerCharacterId: node.message.speakerCharacterId, + state: engine._stateAt(chatId, id), + extra: engine._store.rawNodeExtra(chatId, id), + original: _copy(node.original), + ); + }); + } + + List children(String? parent, {bool physical = false}) { + final ids = [ + for (final entry in links.entries) + if (entry.value.parentId == parent && !removed.contains(entry.key) && (physical || !entry.value.deleted)) + entry.key, + ]; + ids.sort((left, right) => links[left]!.sequence.compareTo(links[right]!.sequence)); + return ids; + } + + void add(_ScriptWorkingPage value, String? parent) { + if (links.containsKey(value.id)) { + fail(TavernScriptErrorCode.duplicateId, 'Allocated page id is already present.'); + } + pages[value.id] = value; + links[value.id] = _ScriptPlannedLink(parent, null, ++sequence, false); + affected.add(value.id); + } + + void reparent(String id, String? parent) { + final link = links[id]; + if (link == null || removed.contains(id)) { + fail(TavernScriptErrorCode.notFound, 'Missing page in a tree plan.', nodeId: id); + } + if (link.parentId == parent) { + return; + } + link.parentId = parent; + if (!link.deleted) { + affected.add(id); + } + } + + void select(String id) { + if (!links.containsKey(id) || links[id]!.deleted || removed.contains(id)) { + fail(TavernScriptErrorCode.notFound, 'The selected page is no longer available.', nodeId: id); + } + final old = path.where((candidate) => links[candidate]!.parentId == links[id]!.parentId).firstOrNull; + final ancestors = []; + final visited = {}; + String? current = id; + while (current != null) { + if (!visited.add(current) || + !links.containsKey(current) || + links[current]!.deleted || + removed.contains(current)) { + fail(TavernScriptErrorCode.invalidArgument, 'The selected page has an invalid ancestor path.'); + } + ancestors.add(current); + current = links[current]!.parentId; + } + final next = ancestors.reversed.toList(); + current = links[id]!.selectedChildId; + while (current != null) { + if (!visited.add(current) || + !links.containsKey(current) || + links[current]!.deleted || + removed.contains(current) || + links[current]!.parentId != next.last) { + fail(TavernScriptErrorCode.invalidArgument, 'The selected page has an invalid remembered child.'); + } + next.add(current); + current = links[current]!.selectedChildId; + } + if (!_stateJsonEqual(path, next)) { + swiped.add((before: old, after: id)); + affected.add(id); + } + path = next; + for (var index = 0; index < path.length; index++) { + links[path[index]]!.selectedChildId = index + 1 < path.length ? path[index + 1] : null; + } + } + + void setText(_ScriptWorkingPage target, String value) { + if (target.text == value) { + return; + } + target.text = value; + target.dirty.add('message'); + edited.add(target.id); + affected.add(target.id); + } + + void setExtra(_ScriptWorkingPage target, Map value) { + final next = { + ..._copy(value), + if (target.extra.containsKey('dshgo')) 'dshgo': target.extra['dshgo'], + }; + if (_stateJsonEqual(target.extra, next)) { + return; + } + target.extra = next; + target.dirty.add('extra'); + updated.add(target.id); + affected.add(target.id); + } + + void setInfo(_ScriptWorkingPage target, Map value) { + final original = _copy(target.original); + final rawIndex = original['swipe_id']; + final index = rawIndex is num && rawIndex.isFinite && rawIndex >= 0 && rawIndex == rawIndex.roundToDouble() + ? rawIndex.toInt() + : 0; + final old = original['swipe_info']; + final infos = old is List ? List.of(old) : []; + while (infos.length <= index) { + infos.add(null); + } + infos[index] = _copy(value); + original['swipe_info'] = infos; + for (final key in const ['send_date', 'gen_started', 'gen_finished']) { + if (value.containsKey(key)) { + original[key] = value[key]; + } else { + original.remove(key); + } + } + if (!_stateJsonEqual(target.original, original)) { + target.original = original; + target.dirty.add('original'); + updated.add(target.id); + affected.add(target.id); + } + if (value['extra'] case final Map extra) { + setExtra(target, extra); + } + } + + void setData(_ScriptWorkingPage target, Map value, _ScriptDataContext context) { + final result = owner._prepareMessageData( + context, + nodeId: target.id, + baseline: target.state, + data: value, + extra: target.extra, + ); + target.state = _copy(result.state); + prepared.add((id: target.id, value: result)); + affected.add(target.id); + } + + void applyPatch(_CapturedMessagePatch captured, _ScriptDataContext? dataContext) { + final originalTarget = page(captured.nodeId); + final parent = links[captured.nodeId]!.parentId; + var family = children(parent); + final fields = captured.fields; + final supplied = fields['swipes'] as List?; + if (supplied != null) { + final retained = captured.familyIds.take(min(supplied.length, captured.familyIds.length)).toList(); + for (final id in retained) { + if (!family.contains(id)) { + fail( + TavernScriptErrorCode.revisionConflict, + 'A prior batch operation changed a retained page family.', + nodeId: id, + ); + } + } + final desired = [...retained, ...captured.newIds]; + if (desired.length != supplied.length) { + fail(TavernScriptErrorCode.invalidArgument, 'Invalid resulting swipe count.'); + } + for (final id in captured.newIds) { + final state = _copy(captured.entryState!); + final value = _ScriptWorkingPage( + id: id, + created: true, + role: originalTarget.role, + name: originalTarget.name, + text: originalTarget.text, + hidden: originalTarget.hidden, + speakerCharacterId: originalTarget.speakerCharacterId, + state: state, + extra: _scriptUserExtra(originalTarget.extra), + original: {}, + ); + add(value, parent); + // An inherited object traverses the common validator without changing + // dshgo or its runtime. Absent/malformed historical data is inherited + // verbatim, never silently replaced by an empty successful object. + if (state['messageVariables'] case final Map inherited) { + setData(value, inherited, dataContext!); + } + } + final toRemove = family.where((id) => !desired.contains(id)).toSet(); + if (!fields.containsKey('swipeId') && (toRemove.contains(captured.nodeId) || path.any(toRemove.contains))) { + fail(TavernScriptErrorCode.invalidArgument, 'Removing a target or selected page requires explicit swipeId.'); + } + final recipient = desired[captured.selectedIndex]; + // Keep the recipient's own remembered continuation, including null. + // Newly attached futures never replace it or become an implicit first child. + for (final id in toRemove) { + for (final child in children(id, physical: true)) { + reparent(child, recipient); + } + removed.add(id); + affected.add(id); + } + for (final link in links.values) { + if (removed.contains(link.selectedChildId)) { + link.selectedChildId = null; + } + } + family = desired; + for (var index = 0; index < family.length; index++) { + setText(page(family[index]), supplied[index]! as String); + } + if (toRemove.isNotEmpty || captured.newIds.isNotEmpty) { + updated.add(recipient); + affected.add(recipient); + } + } + final targetId = fields.containsKey('swipeId') + ? (supplied == null ? captured.familyIds : family)[captured.selectedIndex] + : captured.nodeId; + final target = page(targetId); + final datas = fields['swipesData'] as List?; + final infos = fields['swipesInfo'] as List?; + for (final values in [datas, infos]) { + if (values != null && values.length != family.length) { + fail(TavernScriptErrorCode.invalidArgument, 'A swipe object list does not match the current batch family.'); + } + } + if (infos != null) { + for (var index = 0; index < family.length; index++) { + setInfo(page(family[index]), infos[index]! as Map); + } + } + if (fields['extra'] case final Map extra) { + setExtra(target, extra); + } + if (fields['name'] case final String name) { + if (target.name != name) { + target.name = name; + target.dirty.add('name'); + updated.add(targetId); + affected.add(targetId); + } + } + if (fields['role'] case final String role) { + final requested = TavernScriptRole.values.byName(role); + if (_scriptRole(target.role) != requested) { + target.role = _scriptNativeRole(requested); + target.dirty.add('role'); + updated.add(targetId); + affected.add(targetId); + } + } + if (fields['hidden'] case final bool hidden) { + if (target.hidden != hidden) { + target.hidden = hidden; + target.dirty.add('hidden'); + updated.add(targetId); + affected.add(targetId); + } + // Native setHidden also owns this saved ST exclusion marker. An + // explicit bridge write must not leave it sticky when the column has + // already changed; an omitted hidden field still preserves it verbatim. + if (target.original.containsKey('is_system') && target.original['is_system'] != hidden) { + target.original['is_system'] = hidden; + target.dirty.add('original'); + updated.add(targetId); + affected.add(targetId); + } + } + if (fields.containsKey('speakerCharacterId') && target.speakerCharacterId != fields['speakerCharacterId']) { + target.speakerCharacterId = fields['speakerCharacterId'] as String?; + target.dirty.add('speakerCharacterId'); + updated.add(targetId); + affected.add(targetId); + } + if (supplied == null) { + if (fields['message'] case final String text) { + setText(target, text); + } + } + if (datas != null) { + for (var index = 0; index < family.length; index++) { + setData(page(family[index]), datas[index]! as Map, dataContext!); + } + } else if (fields['data'] case final Map data) { + setData(target, data, dataContext!); + } + if (fields.containsKey('swipeId')) { + select(targetId); + } + } + + void deleteFamilies(Set targets) { + for (final id in targets) { + page(id); + removed.addAll(children(links[id]!.parentId)); + } + for (final entry in links.entries) { + if (removed.contains(entry.key)) { + continue; + } + var parent = entry.value.parentId; + final seen = {}; + while (parent != null && removed.contains(parent)) { + if (!seen.add(parent)) { + fail(TavernScriptErrorCode.invalidArgument, 'Invalid deleted ancestor chain.'); + } + parent = links[parent]!.parentId; + } + reparent(entry.key, parent); + } + for (final link in links.values) { + if (removed.contains(link.selectedChildId)) { + link.selectedChildId = null; + } + } + path = path.where((id) => !removed.contains(id)).toList(); + affected.addAll(removed); + } + + void reorder(List desired) { + final families = >{ + for (final node in beforePath) node.message.nodeId: children(node.message.parentId, physical: true), + }; + for (var index = 0; index < desired.length; index++) { + final parent = index == 0 ? null : desired[index - 1]; + for (final id in families[desired[index]]!) { + reparent(id, parent); + } + } + path = desired; + } + + TavernScriptMutation commit() { + final beforeChat = engine._store.chat(chatId); + final storedLatest = removed.isEmpty ? null : engine._storedLatestInspection(beforeChat); + final latestPointer = _map(_map(beforeChat.metadata['dshgo'])['latestInspection'])['nodeId']; + final storedAnchor = storedLatest?.nodeId ?? storedLatest?.contextNodeId; + final cachedLatest = engine._latestInspections[chatId]; + final cachedAnchor = cachedLatest?.nodeId ?? cachedLatest?.contextNodeId; + clearCachedInspection = cachedAnchor != null && removed.contains(cachedAnchor); + final pending = [ + for (final value in pages.values) + if (value.created && !removed.contains(value.id)) value, + ]; + final inserted = {}; + while (pending.isNotEmpty) { + var progress = false; + for (final value in List<_ScriptWorkingPage>.of(pending)) { + final parent = links[value.id]!.parentId; + if (parent != null && !originalLinks.containsKey(parent) && !inserted.contains(parent)) { + continue; + } + engine._store.append( + chatId, + id: value.id, + parentId: parent, + role: value.role, + speaker: value.name, + speakerCharacterId: value.speakerCharacterId, + text: value.text, + hidden: value.hidden, + state: value.state, + extra: value.extra, + original: value.original, + makeCurrent: false, + ); + inserted.add(value.id); + pending.remove(value); + progress = true; + } + if (!progress) { + fail(TavernScriptErrorCode.invalidArgument, 'New pages contain an invalid parent cycle.'); + } + } + for (final value in pages.values) { + if (value.created || removed.contains(value.id) || value.dirty.isEmpty) { + continue; + } + engine._store.updateMessage( + chatId, + value.id, + role: value.dirty.contains('role') ? value.role : null, + speaker: value.dirty.contains('name') ? value.name : null, + hidden: value.dirty.contains('hidden') ? value.hidden : null, + text: value.dirty.contains('message') ? value.text : null, + speakerCharacterId: value.dirty.contains('speakerCharacterId') ? value.speakerCharacterId : null, + clearSpeakerCharacterId: value.dirty.contains('speakerCharacterId') && value.speakerCharacterId == null, + extra: value.dirty.contains('extra') ? value.extra : null, + original: value.dirty.contains('original') ? value.original : null, + ); + } + final edits = {}; + for (final item in prepared) { + if (removed.contains(item.id)) { + continue; + } + owner._commitMessageData(chatId, item.id, item.value); + if (item.value.stateEdit case final edit?) { + edits[item.id] = edit; + } + } + final parents = { + for (final entry in links.entries) + if (!removed.contains(entry.key) && + originalLinks.containsKey(entry.key) && + originalLinks[entry.key]!.parentId != entry.value.parentId) + entry.key: entry.value.parentId, + }; + final choices = {}; + for (final entry in links.entries) { + if (removed.contains(entry.key) || + !originalLinks.containsKey(entry.key) || + originalLinks[entry.key]!.selectedChildId == entry.value.selectedChildId) { + continue; + } + final child = entry.value.selectedChildId; + if (child == null || !removed.contains(child) && links[child]?.parentId == entry.key && !links[child]!.deleted) { + choices[entry.key] = child; + } + } + final pathChanged = !_stateJsonEqual([for (final node in beforePath) node.message.nodeId], path); + if (parents.isNotEmpty || choices.isNotEmpty || inserted.isNotEmpty || removed.isNotEmpty || pathChanged) { + engine._store.rewriteTree( + chatId, + parentByNode: parents, + selectedPath: path, + selectedChildren: choices, + deleteNodeIds: { + for (final id in removed) + if (originalLinks.containsKey(id)) id, + }, + capturePortableState: (node, physical) { + final override = _map(node.extra['dshgo'])['tavernState']; + return engine._withStateHash(beforeChat, _copy(override is Map ? override : physical)); + }, + ); + } + changed = owner._pathRevision(chatId) != oldRevision; + if (changed) { + engine._restoreVariables(chatId); + if (removed.contains(latestPointer) || storedAnchor != null && removed.contains(storedAnchor)) { + engine._writeLatestInspection(chatId, null); + } + } + final after = engine._historyTo(chatId, engine._store.chat(chatId).currentLeafId); + final changedIndices = {}; + for (var index = 0; index < max(beforePath.length, after.length); index++) { + final oldId = index < beforePath.length ? beforePath[index].message.nodeId : null; + final newId = index < after.length ? after[index].message.nodeId : null; + if (oldId != newId) { + changedIndices[index] = newId == null ? null : _scriptRef(chatId, newId); + } + } + final affectedParents = { + for (final id in affected) + if (links.containsKey(id) && !removed.contains(id)) links[id]!.parentId, + }; + return TavernScriptMutation( + oldRevision: oldRevision, + pathRevision: owner._pathRevision(chatId), + affectedRefs: [ + for (final id in affected) + if (!removed.contains(id)) _scriptRef(chatId, id), + ], + removedRefs: [ + for (final id in removed) + if (originalLinks.containsKey(id)) _scriptRef(chatId, id), + ], + indexChanges: changedIndices, + refresh: options.refresh, + stateEdits: edits, + upsertMessages: [ + for (var index = 0; index < after.length; index++) + if (changedIndices.containsKey(index) || + affected.contains(after[index].message.nodeId) || + affectedParents.contains(after[index].message.parentId)) + owner._projectMessage(chatId, after[index], index, includeSwipes: true), + ], + ); + } + + /// Only synchronous in-memory adoption belongs in the outer COMMIT hook. + /// No SQL, host callback or cache invalidation runs before a successful commit. + void adoptCommitted() { + if (changed) { + engine._liveVariables.remove(chatId); + } + if (clearCachedInspection) { + engine._latestInspections.remove(chatId); + } + } + + List<_ScriptLifecycle> lifecycle(TavernScriptMutation mutation) { + final indices = {for (var index = 0; index < path.length; index++) path[index]: index}; + final result = <_ScriptLifecycle>[]; + void add(String name, String id) { + if (removed.contains(id)) { + return; + } + result.add(_ScriptLifecycle(name: name, nodeRef: _scriptRef(chatId, id), args: [?indices[id]])); + } + + for (final id in edited) { + add('MESSAGE_EDITED', id); + } + for (final id in updated) { + add('MESSAGE_UPDATED', id); + } + for (final selection in swiped) { + if (removed.contains(selection.after)) { + continue; + } + result.add( + _ScriptLifecycle( + name: 'MESSAGE_SWIPED', + nodeRef: _scriptRef(chatId, selection.after), + args: [ + ?indices[selection.after], + { + 'oldRef': selection.before == null ? null : {'chatId': chatId, 'nodeId': selection.before}, + 'newRef': {'chatId': chatId, 'nodeId': selection.after}, + 'swipeId': children(links[selection.after]!.parentId).indexOf(selection.after), + }, + ], + ), + ); + } + if (mutation.removedRefs.isNotEmpty) { + final oldFloor = { + for (var index = 0; index < beforePath.length; index++) beforePath[index].message.parentId: index, + }; + result.add( + _ScriptLifecycle( + name: 'MESSAGE_DELETED', + args: [ + { + 'removed': [ + for (final ref in mutation.removedRefs) + { + 'chatId': ref.chatId, + 'nodeId': ref.nodeId, + 'messageId': oldFloor[originalLinks[ref.nodeId]!.parentId], + }, + ], + 'pathRevision': mutation.pathRevision, + }, + ], + ), + ); + } + return result; + } +} diff --git a/packages/dsh_tavern/lib/src/io/chat_jsonl.dart b/packages/dsh_tavern/lib/src/io/chat_jsonl.dart index 68c4da52ff..553739851f 100644 --- a/packages/dsh_tavern/lib/src/io/chat_jsonl.dart +++ b/packages/dsh_tavern/lib/src/io/chat_jsonl.dart @@ -800,9 +800,10 @@ final class _Variant { name = _text(original['name']); final user = original['is_user'] == true; final roleName = own['version'] == 1 ? own['role'] : null; + // A portable character/system role takes precedence over the ST type hint. role = user ? TavernRole.user - : extra['type'] == 'narrator' || roleName == 'narrator' + : roleName == 'narrator' || (extra['type'] == 'narrator' && roleName != 'character' && roleName != 'system') ? TavernRole.narrator : roleName == 'system' ? TavernRole.system diff --git a/packages/dsh_tavern/lib/src/scripts.dart b/packages/dsh_tavern/lib/src/scripts.dart new file mode 100644 index 0000000000..2222393ad8 --- /dev/null +++ b/packages/dsh_tavern/lib/src/scripts.dart @@ -0,0 +1,755 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'api.dart' show TavernFailure, TavernRole; +import 'state.dart'; + +typedef TavernJsonObject = Map; + +enum TavernVariableScope { global, character, chat, message, script } + +enum TavernScriptOwnerKind { global, character, preset } + +enum TavernScriptRole { user, assistant, system } + +enum TavernHiddenFilter { all, hiddenOnly, visibleOnly } + +enum TavernScriptRefresh { none, affected, all } + +enum TavernVariableChangeKind { set, insertIfAbsent, remove } + +enum TavernScriptDispatchKind { cache, lifecycle } + +enum TavernScriptAckStatus { completed, failed, cancelled } + +enum TavernScriptErrorCode { + noContext, + closedContext, + notFound, + ambiguousName, + duplicateId, + range, + invalidArgument, + invalidJson, + reservedField, + shapeMismatch, + stateValidation, + revisionConflict, + busy, + unsupportedCommand, + unsupportedOption, + unsupportedCapability, + hostUnavailable, + callbackUnavailable, + cancelled, + storage, + model, + hookFailure, +} + +/// Persistent script identity. An iframe instance is deliberately not a key. +class TavernScriptOwner { + const TavernScriptOwner({required this.kind, required this.scriptId, this.assetId}); + final TavernScriptOwnerKind kind; + final String? assetId; + final String scriptId; + @override + bool operator ==(Object other) => + other is TavernScriptOwner && kind == other.kind && assetId == other.assetId && scriptId == other.scriptId; + @override + int get hashCode => Object.hash(kind, assetId, scriptId); +} + +class TavernScriptContextSpec { + const TavernScriptContextSpec({required this.instanceId, this.chatId, this.characterId, this.nodeId, this.script}); + final String instanceId; + final String? chatId; + final String? characterId; + final String? nodeId; + final TavernScriptOwner? script; +} + +class TavernScriptContext { + const TavernScriptContext({ + required this.id, + required this.engineEpoch, + required this.instanceId, + this.chatId, + this.characterId, + this.nodeId, + this.script, + }); + final String id; + final String engineEpoch; + final String instanceId; + final String? chatId; + final String? characterId; + final String? nodeId; + final TavernScriptOwner? script; +} + +class TavernScriptMessageRef { + const TavernScriptMessageRef({required this.chatId, required this.nodeId}); + final String chatId; + final String nodeId; + @override + bool operator ==(Object other) => other is TavernScriptMessageRef && chatId == other.chatId && nodeId == other.nodeId; + @override + int get hashCode => Object.hash(chatId, nodeId); +} + +class TavernMessageRange { + const TavernMessageRange({this.first, this.last}); + final int? first; + final int? last; +} + +class TavernScriptWriteOptions { + const TavernScriptWriteOptions({this.expectedPathRevision, this.refresh = TavernScriptRefresh.affected}); + final String? expectedPathRevision; + final TavernScriptRefresh refresh; +} + +class TavernScriptShapeIssue { + const TavernScriptShapeIssue({required this.fieldPath, required this.actualKind, required this.message}); + final String fieldPath; + final String actualKind; + final String message; +} + +class TavernScriptSwipe { + TavernScriptSwipe({ + required this.ref, + required this.swipeId, + required this.text, + this.hasData = false, + Object? rawData, + this.dataIssue, + TavernJsonObject info = const {}, + }) : _rawData = _scriptCopy(rawData), + _info = _scriptObject(info); + final TavernScriptMessageRef ref; + final int swipeId; + final String text; + final bool hasData; + final Object? _rawData; + final TavernScriptShapeIssue? dataIssue; + final TavernJsonObject _info; + Object? get rawData => _scriptCopy(_rawData); + TavernJsonObject? get data => !hasData + ? {} + : _rawData is TavernJsonObject + ? _scriptObject(_rawData) + : null; + TavernJsonObject get info => _scriptObject(_info); +} + +class TavernScriptMessage { + TavernScriptMessage({ + required this.messageId, + required this.ref, + required this.name, + required this.role, + required this.message, + required this.nativeRole, + this.hidden = false, + this.speakerCharacterId, + this.hasData = false, + Object? rawData, + this.dataIssue, + TavernJsonObject extra = const {}, + this.swipeId = 0, + List swipes = const [], + }) : _rawData = _scriptCopy(rawData), + _extra = _scriptObject(extra), + swipes = List.unmodifiable(swipes); + final int messageId; + final TavernScriptMessageRef ref; + final String name; + final TavernScriptRole role; + final TavernRole nativeRole; + final String message; + final bool hidden; + final String? speakerCharacterId; + final bool hasData; + final Object? _rawData; + final TavernScriptShapeIssue? dataIssue; + final TavernJsonObject _extra; + final int swipeId; + final List swipes; + Object? get rawData => _scriptCopy(_rawData); + TavernJsonObject? get data => !hasData + ? {} + : _rawData is TavernJsonObject + ? _scriptObject(_rawData) + : null; + TavernJsonObject get extra => _scriptObject(_extra); +} + +class TavernScriptMessagePage { + TavernScriptMessagePage({ + required this.pathRevision, + required this.totalPathLength, + required List messages, + }) : messages = List.unmodifiable(messages); + final String pathRevision; + final int totalPathLength; + final List messages; +} + +class TavernScriptMessagePatch { + TavernScriptMessagePatch({this.messageId, this.ref, required TavernJsonObject fields}) + : _fields = _scriptObject(fields); + final int? messageId; + final TavernScriptMessageRef? ref; + final TavernJsonObject _fields; + TavernJsonObject get fields => _scriptObject(_fields); +} + +class TavernScriptMessageDraft { + TavernScriptMessageDraft({ + required this.role, + required this.message, + this.name, + this.speakerCharacterId, + this.hidden = false, + TavernJsonObject? data, + TavernJsonObject extra = const {}, + }) : _data = data == null ? null : _scriptObject(data), + _extra = _scriptObject(extra); + final TavernScriptRole role; + final String message; + final String? name; + final String? speakerCharacterId; + final bool hidden; + final TavernJsonObject? _data; + final TavernJsonObject _extra; + + /// Null means omitted in the native DTO. A wire adapter rejects explicit null. + TavernJsonObject? get data => _data == null ? null : _scriptObject(_data); + TavernJsonObject get extra => _scriptObject(_extra); +} + +class TavernScriptMutation { + TavernScriptMutation({ + required this.oldRevision, + required this.pathRevision, + List upsertMessages = const [], + List affectedRefs = const [], + List removedRefs = const [], + Map indexChanges = const {}, + this.refresh = TavernScriptRefresh.affected, + Map stateEdits = const {}, + }) : upsertMessages = List.unmodifiable(upsertMessages), + affectedRefs = List.unmodifiable(affectedRefs), + removedRefs = List.unmodifiable(removedRefs), + indexChanges = Map.unmodifiable(indexChanges), + stateEdits = Map.unmodifiable(stateEdits); + final String oldRevision; + final String pathRevision; + final List upsertMessages; + final List affectedRefs; + final List removedRefs; + final Map indexChanges; + final TavernScriptRefresh refresh; + final Map stateEdits; +} + +class TavernVariableTarget { + const TavernVariableTarget({ + required this.scope, + this.characterId, + this.chatId, + this.messageRef, + this.messageId, + this.scriptOwner, + }); + final TavernVariableScope scope; + final String? characterId; + final String? chatId; + final TavernScriptMessageRef? messageRef; + final int? messageId; + final TavernScriptOwner? scriptOwner; +} + +class TavernVariableSnapshot { + TavernVariableSnapshot({ + required this.resolvedTarget, + required this.version, + required TavernJsonObject value, + this.stateEdit, + }) : _value = _scriptObject(value); + final TavernVariableTarget resolvedTarget; + final String version; + final TavernJsonObject _value; + final TavernStateEditResult? stateEdit; + TavernJsonObject get value => _scriptObject(_value); +} + +const Object _scriptAbsent = Object(); + +class TavernVariableChange { + TavernVariableChange({required this.kind, required List pathTokens, Object? value = _scriptAbsent}) + : pathTokens = List.unmodifiable(pathTokens), + hasValue = !identical(value, _scriptAbsent), + _value = identical(value, _scriptAbsent) ? null : _scriptCopy(value); + final TavernVariableChangeKind kind; + final List pathTokens; + final bool hasValue; + final Object? _value; + Object? get value => _scriptCopy(_value); +} + +class TavernVariableChangeResult { + TavernVariableChangeResult({ + required this.kind, + required List pathTokens, + required this.existed, + required this.changed, + }) : pathTokens = List.unmodifiable(pathTokens); + final TavernVariableChangeKind kind; + final List pathTokens; + final bool existed; + final bool changed; +} + +class TavernVariableMutation { + TavernVariableMutation({required this.snapshot, required List results}) + : results = List.unmodifiable(results); + final TavernVariableSnapshot snapshot; + final List results; +} + +class TavernScriptCapabilities { + TavernScriptCapabilities({ + required this.adapterVersion, + required Set methods, + required Set scopes, + Set slashCommands = const {}, + Set generationOptions = const {}, + required this.hostAttached, + this.simulated = false, + }) : methods = Set.unmodifiable(methods), + scopes = Set.unmodifiable(scopes), + slashCommands = Set.unmodifiable(slashCommands), + generationOptions = Set.unmodifiable(generationOptions); + final String adapterVersion; + final Set methods; + final Set scopes; + final Set slashCommands; + final Set generationOptions; + final bool hostAttached; + final bool simulated; +} + +class TavernScriptSnapshot { + TavernScriptSnapshot({ + required this.context, + required this.engineEpoch, + required this.lastSeq, + required this.pathRevision, + required List messages, + List variableSnapshots = const [], + this.presetId, + this.personaId, + List memberCharacterIds = const [], + TavernJsonObject resourceRefs = const {}, + required this.capabilities, + }) : messages = List.unmodifiable(messages), + variableSnapshots = List.unmodifiable(variableSnapshots), + memberCharacterIds = List.unmodifiable(memberCharacterIds), + _resourceRefs = _scriptObject(resourceRefs); + final TavernScriptContext context; + final String engineEpoch; + final int lastSeq; + final String pathRevision; + final List messages; + final List variableSnapshots; + final String? presetId; + final String? personaId; + final List memberCharacterIds; + final TavernJsonObject _resourceRefs; + final TavernScriptCapabilities capabilities; + TavernJsonObject get resourceRefs => _scriptObject(_resourceRefs); +} + +class TavernScriptDelta { + TavernScriptDelta({ + required this.baseRevision, + required this.newRevision, + List upsertMessages = const [], + List removedRefs = const [], + Map indexChanges = const {}, + List variableSnapshots = const [], + TavernJsonObject resourceRefs = const {}, + }) : upsertMessages = List.unmodifiable(upsertMessages), + removedRefs = List.unmodifiable(removedRefs), + indexChanges = Map.unmodifiable(indexChanges), + variableSnapshots = List.unmodifiable(variableSnapshots), + _resourceRefs = _scriptObject(resourceRefs); + final String baseRevision; + final String newRevision; + final List upsertMessages; + final List removedRefs; + final Map indexChanges; + final List variableSnapshots; + final TavernJsonObject _resourceRefs; + TavernJsonObject get resourceRefs => _scriptObject(_resourceRefs); +} + +class TavernScriptSourceSpan { + const TavernScriptSourceSpan({ + this.sourceId, + required this.start, + required this.end, + required this.line, + required this.column, + }); + final String? sourceId; + final int start; + final int end; + final int line; + final int column; +} + +class TavernScriptIssue { + TavernScriptIssue({ + required this.code, + required this.message, + required this.committed, + TavernJsonObject details = const {}, + this.contextId, + this.eventId, + this.generationId, + this.nodeRef, + this.sourceSpan, + this.stage, + this.cancelled = false, + }) : _details = _scriptObject(details); + final String code; + final String message; + final bool committed; + final TavernJsonObject _details; + final String? contextId; + final String? eventId; + final String? generationId; + final TavernScriptMessageRef? nodeRef; + final TavernScriptSourceSpan? sourceSpan; + final String? stage; + final bool cancelled; + TavernJsonObject get details => _scriptObject(_details); +} + +class TavernScriptException implements Exception { + TavernScriptException({ + required this.code, + required this.operation, + required this.message, + this.contextId, + this.nodeId, + this.generationId, + this.executionId, + this.fieldPath, + this.sourceSpan, + this.committed = false, + this.modelFailure, + List issues = const [], + this.stateEdit, + }) : issues = List.unmodifiable(issues); + final TavernScriptErrorCode code; + final String operation; + final String message; + final String? contextId; + final String? nodeId; + final String? generationId; + final String? executionId; + final String? fieldPath; + final TavernScriptSourceSpan? sourceSpan; + final bool committed; + final TavernFailure? modelFailure; + final List issues; + final TavernStateEditResult? stateEdit; + @override + String toString() => 'TavernScriptException(${code.name}, $operation): $message'; +} + +class TavernScriptDispatch { + TavernScriptDispatch({ + required this.kind, + required this.eventId, + required this.contextId, + required this.seq, + this.chatId, + this.namespace, + this.name, + this.generationId, + this.nodeRef, + this.messageId, + this.swipeId, + this.pathRevision, + List args = const [], + this.delta, + }) : _args = _scriptCopy(args)! as List; + final TavernScriptDispatchKind kind; + final String eventId; + final String contextId; + final String? chatId; + final String? namespace; + final String? name; + final int seq; + final String? generationId; + final TavernScriptMessageRef? nodeRef; + final int? messageId; + final int? swipeId; + final String? pathRevision; + final List _args; + final TavernScriptDelta? delta; + List get args => _scriptCopy(_args)! as List; +} + +class TavernScriptDispatchAck { + TavernScriptDispatchAck({ + required this.eventId, + this.status = TavernScriptAckStatus.completed, + List issues = const [], + Map revisions = const {}, + }) : issues = List.unmodifiable(issues), + revisions = Map.unmodifiable(revisions); + final String eventId; + final TavernScriptAckStatus status; + final List issues; + final Map revisions; +} + +class TavernScriptEvent { + TavernScriptEvent({ + required this.dispatch, + required this.phase, + required this.committed, + List issues = const [], + }) : issues = List.unmodifiable(issues); + final TavernScriptDispatch dispatch; + final String phase; + final bool committed; + final List issues; + String get contextId => dispatch.contextId; + String? get chatId => dispatch.chatId; + String? get name => dispatch.name; + int get seq => dispatch.seq; +} + +abstract interface class TavernScriptCancellation { + bool get isCancelled; + Future get whenCancelled; +} + +class TavernScriptRefreshRequest { + TavernScriptRefreshRequest({ + required this.requestId, + required this.contextId, + required this.mode, + required this.pathRevision, + required List refs, + required this.reason, + }) : refs = List.unmodifiable(refs); + final String requestId; + final String contextId; + final TavernScriptRefresh mode; + final String pathRevision; + final List refs; + final String reason; +} + +/// JSON-only host messages. UI and callback execution are supplied by the App. +class TavernScriptUiRequest { + TavernScriptUiRequest({ + required this.requestId, + required this.contextId, + required this.executionId, + required this.kind, + TavernJsonObject fields = const {}, + }) : _fields = _scriptObject(fields); + final String requestId; + final String contextId; + final String executionId; + final String kind; + final TavernJsonObject _fields; + TavernJsonObject get fields => _scriptObject(_fields); +} + +class TavernScriptUiResult { + const TavernScriptUiResult({required this.requestId, required this.status, this.text, this.buttonIndex, this.label}); + final String requestId; + final String status; + final String? text; + final int? buttonIndex; + final String? label; +} + +class TavernScriptCallbackRef { + const TavernScriptCallbackRef({required this.contextId, required this.instanceId, required this.callbackId}); + final String contextId; + final String instanceId; + final String callbackId; +} + +class TavernScriptCallbackRequest { + TavernScriptCallbackRequest({ + required this.requestId, + required this.ref, + required this.kind, + List args = const [], + }) : _args = _scriptCopy(args)! as List; + final String requestId; + final TavernScriptCallbackRef ref; + final String kind; + final List _args; + List get args => _scriptCopy(_args)! as List; +} + +class TavernScriptCallbackResult { + TavernScriptCallbackResult({ + required this.requestId, + required this.status, + this.value, + List issues = const [], + }) : issues = List.unmodifiable(issues); + final String requestId; + final String status; + final bool? value; + final List issues; +} + +class TavernSlashReference { + const TavernSlashReference({required this.contextId, required this.label, this.setName, this.sourceSpan}); + final String contextId; + final String label; + final String? setName; + final TavernScriptSourceSpan? sourceSpan; +} + +class TavernResolvedSlash { + const TavernResolvedSlash({required this.assetId, required this.version, required this.source}); + final String assetId; + final String version; + final String source; +} + +abstract interface class TavernScriptHost { + Future dispatch(TavernScriptDispatch request, TavernScriptCancellation cancellation); + Future interact(TavernScriptUiRequest request, TavernScriptCancellation cancellation); + Future invoke(TavernScriptCallbackRequest request, TavernScriptCancellation cancellation); + Future resolveSlash(TavernSlashReference request, TavernScriptCancellation cancellation); + Future refresh(TavernScriptRefreshRequest request); +} + +/// B1's persisted data and awaited lifecycle surface. Capabilities never claim +/// the later background-generation, injection or STscript implementations. +abstract interface class TavernScriptBridge { + TavernScriptCapabilities get capabilities; + void attachHost(TavernScriptHost? host); + Future openContext(TavernScriptContextSpec spec); + Future snapshot(String contextId); + Future closeContext(String contextId); + Future activateChat(String contextId); + Stream watchEvents({String? chatId}); + Future getChatMessages( + String contextId, + TavernMessageRange range, { + TavernScriptRole? role, + TavernHiddenFilter hidden = TavernHiddenFilter.all, + bool includeSwipes = false, + }); + Future setChatMessages( + String contextId, + List patches, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }); + Future createChatMessages( + String contextId, + List messages, { + int? insertBefore, + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }); + Future deleteChatMessages( + String contextId, + List messageIds, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }); + Future rotateChatMessages( + String contextId, + int begin, + int middle, + int end, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }); + Future selectSwipe( + String contextId, + TavernScriptMessageRef message, + int swipeId, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }); + Future getVariables(String contextId, TavernVariableTarget target); + Future replaceVariables( + String contextId, + TavernVariableTarget target, + TavernJsonObject value, { + String? expectedVersion, + }); + Future updateVariablesWith( + String contextId, + TavernVariableTarget target, + FutureOr Function(TavernJsonObject value) updater, + ); + Future mutateVariables( + String contextId, + TavernVariableTarget target, + List changes, { + String? expectedVersion, + }); +} + +Object? _scriptCopy(Object? value, [Set? seen]) { + if (value == null || value is String || value is bool) { + return value; + } + if (value is num && value.isFinite) { + return value; + } + final active = seen ?? HashSet.identity(); + if (value is Map || value is List) { + if (!active.add(value)) { + throw TavernScriptException( + code: TavernScriptErrorCode.invalidJson, + operation: 'capture', + message: 'Script JSON must not contain a cycle.', + ); + } + try { + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + if (entry.key is! String) { + throw TavernScriptException( + code: TavernScriptErrorCode.invalidJson, + operation: 'capture', + message: 'Script JSON object keys must be strings.', + ); + } + result[entry.key as String] = _scriptCopy(entry.value, active); + } + return result; + } + return [for (final item in value as List) _scriptCopy(item, active)]; + } finally { + active.remove(value); + } + } + throw TavernScriptException( + code: TavernScriptErrorCode.invalidJson, + operation: 'capture', + message: 'Expected finite standard JSON.', + ); +} + +TavernJsonObject _scriptObject(Object? value) => _scriptCopy(value)! as TavernJsonObject; diff --git a/packages/dsh_tavern/lib/src/store/character_library.dart b/packages/dsh_tavern/lib/src/store/character_library.dart index c6728483c6..f2af212f8d 100644 --- a/packages/dsh_tavern/lib/src/store/character_library.dart +++ b/packages/dsh_tavern/lib/src/store/character_library.dart @@ -125,6 +125,10 @@ class CharacterLibrary { final DateTime Function() _now; Future _queue = Future.value(); + /// Internal synchronous notification of the authoritative card-file commit. + /// The observer must not throw, write, await, or depend on the derived index. + void Function(String id, Map data)? cardCommitted; + Directory get _characters => Directory('${root.path}/characters'); Directory get _indexes => Directory('${root.path}/character-index'); File _indexFile(String id) => File('${_indexes.path}/$id.json'); @@ -283,6 +287,37 @@ class CharacterLibrary { }); } + /// Internal bridge persistence: only the two existing helper carriers may + /// change. Other card extensions, assets, original bytes and identity stay + /// intact. The engine supplies the latest detached extension document while + /// owning its character queue; this does not broaden updateFields. + Future updateScriptStorage(String id, Map extensions) { + _requireId(id); + final captured = jsonDecode(jsonEncode(extensions)) as Map; + return _exclusive(() async { + await _tidy(); + final record = await _read(id); + if (record == null) { + throw const TavernImportError('角色不存在。'); + } + final json = jsonDecode(jsonEncode(record.card.json)) as Map; + final data = json['data']! as Map; + final previous = data['extensions']; + if (previous != null && previous is! Map) { + throw const FormatException('Character extensions must be an object.'); + } + final old = previous is Map ? previous : {}; + final keys = {...old.keys, ...captured.keys}..removeAll(const {'tavern_helper', 'TavernHelper_scripts'}); + for (final key in keys) { + if (old.containsKey(key) != captured.containsKey(key) || jsonEncode(old[key]) != jsonEncode(captured[key])) { + throw ArgumentError('Only helper storage can be changed by this operation.'); + } + } + data['extensions'] = captured; + return _saveCard(record, json); + }); + } + /// Adds one CC book entry, assigning an unused integer id. Input is copied /// before queueing. The caller cannot supply `id`; omitted known fields use /// ordinary CC defaults. A missing book starts with an empty entries array. @@ -391,6 +426,7 @@ class CharacterLibrary { try { await pending.writeAsString(jsonEncode(json), flush: true); await pending.rename(cardFile.path); + cardCommitted?.call(record.id, updated.card.data); } finally { try { if (await pending.exists()) { diff --git a/packages/dsh_tavern/lib/src/store/chat_store.dart b/packages/dsh_tavern/lib/src/store/chat_store.dart index 4a54e996b9..8010fbc279 100644 --- a/packages/dsh_tavern/lib/src/store/chat_store.dart +++ b/packages/dsh_tavern/lib/src/store/chat_store.dart @@ -60,6 +60,23 @@ class StoredNode { final Map original; } +/// Identity-only graph used while preparing a structural bridge transaction. +/// Message bodies and full states are read only for pages the plan changes. +class StoredTreeLink { + const StoredTreeLink({ + required this.nodeId, + required this.parentId, + required this.selectedChildId, + required this.sequence, + required this.deleted, + }); + final String nodeId; + final String? parentId; + final String? selectedChildId; + final int sequence; + final bool deleted; +} + /// A saved preset and its original import text. JSON returned by the store is /// detached; execution uses [raw], never the archived [sourceText]. class StoredPreset { @@ -189,6 +206,12 @@ class ChatStore { var _streamingWriters = 0; var _synchronous = 2; + /// Internal synchronous publication after an outer COMMIT and its memory + /// adoption, before asynchronous change notifications. The observer may read + /// registered targets; it must not throw, await, write SQL or call a host. + /// Rollbacks and nested savepoint releases never invoke it. + void Function(Set chatIds, bool presetsChanged)? committedObserver; + Stream get changes => _changes.stream; /// Preset records and selections changed in a committed outer transaction. @@ -403,6 +426,7 @@ class ChatStore { final presetsChanged = _presetsChanged; _changedChats.clear(); _presetsChanged = false; + committedObserver?.call(Set.unmodifiable(changed), presetsChanged); for (final chatId in changed) { _changes.add(chatId); } @@ -823,6 +847,17 @@ class ChatStore { ); } + /// Revision of the committed tree/content/state view. Bridge tokens add an + /// engine epoch; this integer is never exposed to JavaScript as a Number. + int treeRevision(String chatId) => _chatRow(chatId)['tree_revision'] as int; + + /// Allocates an identity without adding a row. A batch can prepare all state + /// reports against their actual node ids before its first INSERT. + String newNodeId() { + _ensureOpen(); + return _createId(); + } + /// A group keeps its real host in chats.character_id. Library existence is /// projected by the engine; the SQL roster never guesses a card by name. TavernGroupChat? groupChat(String chatId) { @@ -1117,6 +1152,39 @@ ORDER BY n.seq ]; } + /// Structural edits retain pending-deletion branches as physical children. + /// This is deliberately separate from the visible swipe projection. + List physicalChildren(String chatId, String? parentId) { + _chatRow(chatId); + if (parentId != null) { + _nodeRow(chatId, parentId, includeDeleted: true); + } + return [ + for (final row in _db.select( + 'SELECT $_nodeColumns, $_siblingColumns FROM nodes n WHERE n.chat_id = ? AND n.parent_id IS ? ORDER BY n.seq', + [chatId, parentId], + )) + _storedNode(row), + ]; + } + + Map treeLinks(String chatId) { + _chatRow(chatId); + return { + for (final row in _db.select( + 'SELECT id, parent_id, selected_child_id, seq, deleted_by FROM nodes WHERE chat_id = ? ORDER BY seq', + [chatId], + )) + row['id'] as String: StoredTreeLink( + nodeId: row['id'] as String, + parentId: row['parent_id'] as String?, + selectedChildId: row['selected_child_id'] as String?, + sequence: row['seq'] as int, + deleted: row['deleted_by'] != null, + ), + }; + } + /// Reads at most [limit] ancestors, starting at the current leaf or just /// before a cursor on that path. The result is always oldest first. List path(String chatId, {int limit = 60, String? beforeNodeId}) { @@ -1289,6 +1357,9 @@ ORDER BY n.seq TavernMessage updateMessage( String chatId, String nodeId, { + TavernRole? role, + String? speaker, + bool? hidden, String? text, String? reasoning, String? speakerCharacterId, @@ -1310,6 +1381,15 @@ ORDER BY n.seq values.add(value); } + if (role != null) { + put('role', role.name); + } + if (speaker != null) { + put('speaker', speaker); + } + if (hidden != null) { + put('hidden', hidden ? 1 : 0); + } // UPDATE OF text drives the FTS trigger. Metadata-only writes must not // name text, and must not promote an in-progress body as a side effect. if (text != null || promote) put('text', text ?? _unguardText(row['stream_text'])); @@ -1631,6 +1711,203 @@ ORDER BY n.seq /// nested arrays or objects; a changed top-level value is replaced whole. Map nodeState(String chatId, String nodeId) => _nodeStateSnapshot(chatId, nodeId).state; + /// Replaces this physical state only. Children become checkpoints of their + /// old physical state before the parent changes, including retained deleted + /// children. Portable overrides stay independent in raw extra; the caller + /// writes the target override/audit in the same outer transaction. + void writeNodeState(String chatId, String nodeId, Map state) { + final encoded = jsonEncode(state); + transaction(() { + _nodeRow(chatId, nodeId); + final children = _db.select('SELECT id FROM nodes WHERE chat_id = ? AND parent_id = ?', [chatId, nodeId]); + final snapshots = { + for (final child in children) + child['id'] as String: jsonEncode( + _nodeStateSnapshot(chatId, child['id'] as String, includeDeleted: true).state, + ), + }; + for (final entry in snapshots.entries) { + _db.execute('UPDATE node_states SET delta_json = ?, full_json = ? WHERE node_id = ?', [ + '[]', + entry.value, + entry.key, + ]); + } + _db.execute('UPDATE node_states SET delta_json = ?, full_json = ? WHERE node_id = ?', ['[]', encoded, nodeId]); + _touch(chatId, tree: true); + }); + } + + /// Reconnects an already prepared family plan without changing node ids or + /// seq (which is the FTS rowid and sibling order). Unmentioned parent edges + /// and attached branches remain. Every surviving child of a removed page + /// must have an explicit surviving parent in the plan. + /// + /// Only roots whose parent changes need new full physical checkpoints. + /// Their descendants continue from the identical physical base. A portable + /// snapshot is computed separately, before any edge or selection changes. + void rewriteTree( + String chatId, { + required Map parentByNode, + required List selectedPath, + Map selectedChildren = const {}, + Set deleteNodeIds = const {}, + Map Function(StoredNode node, Map physicalState)? capturePortableState, + }) { + final requestedParents = Map.of(parentByNode); + final requestedChoices = Map.of(selectedChildren); + final path = List.of(selectedPath); + final removed = Set.of(deleteNodeIds); + transaction(() { + final chat = _chatRow(chatId); + final rows = _db.select( + 'SELECT id, parent_id, selected_child_id, depth, deleted_by FROM nodes WHERE chat_id = ? ORDER BY seq', + [chatId], + ); + final byId = {for (final row in rows) row['id'] as String: row}; + for (final id in {...requestedParents.keys, ...removed}) { + if (!byId.containsKey(id)) { + throw StateError('Tree plan contains a foreign or missing node: $id'); + } + } + if (requestedParents.keys.any(removed.contains)) { + throw ArgumentError('A removed page cannot also be reparented.'); + } + final parents = { + for (final row in rows) + if (!removed.contains(row['id'])) + row['id'] as String: requestedParents.containsKey(row['id']) + ? requestedParents[row['id']] + : row['parent_id'] as String?, + }; + for (final entry in parents.entries) { + if (entry.value != null && !parents.containsKey(entry.value)) { + throw ArgumentError('Tree plan leaves ${entry.key} under a missing parent.'); + } + } + + // Compute depths iteratively, so a long real chat does not recurse on + // the Dart stack. The identity graph also detects cycles before SQL. + final depths = {}; + for (final start in parents.keys) { + if (depths.containsKey(start)) { + continue; + } + final chain = []; + final visiting = {}; + String? cursor = start; + while (cursor != null && !depths.containsKey(cursor)) { + if (!visiting.add(cursor)) { + throw ArgumentError('Tree plan contains a parent cycle.'); + } + chain.add(cursor); + cursor = parents[cursor]; + } + var depth = cursor == null ? 0 : depths[cursor]!; + for (final id in chain.reversed) { + depths[id] = ++depth; + } + } + final seen = {}; + String? previous; + for (final id in path) { + if (!seen.add(id) || !parents.containsKey(id) || byId[id]!['deleted_by'] != null || parents[id] != previous) { + throw ArgumentError('Selected path must be a visible, complete path of the planned tree.'); + } + previous = id; + } + + final choices = {}; + for (final id in parents.keys) { + final child = byId[id]!['selected_child_id'] as String?; + choices[id] = child != null && parents.containsKey(child) && parents[child] == id ? child : null; + } + for (final entry in requestedChoices.entries) { + if (!parents.containsKey(entry.key) || + entry.value != null && + (!parents.containsKey(entry.value) || + parents[entry.value] != entry.key || + byId[entry.value]!['deleted_by'] != null)) { + throw ArgumentError('Remembered selection must identify a surviving direct child.'); + } + choices[entry.key] = entry.value; + } + for (var index = 0; index < path.length; index++) { + choices[path[index]] = index + 1 < path.length ? path[index + 1] : null; + } + final changedParents = { + for (final entry in requestedParents.entries) + if (byId[entry.key]!['parent_id'] != entry.value) entry.key: entry.value, + }; + final snapshots = {}; + for (final id in changedParents.keys) { + final row = _nodeRow(chatId, id, includeDeleted: true); + final state = _nodeStateSnapshot(chatId, id, includeDeleted: true).state; + final physical = jsonEncode(state); + String? encodedExtra; + if (capturePortableState != null) { + final portable = capturePortableState(_storedNode(row), state); + final extra = _map(row['extra']); + final own = extra['dshgo']; + if (extra.containsKey('dshgo') && own is! Map) { + throw StateError('Cannot preserve a malformed internal dshgo container while moving a page.'); + } + extra['dshgo'] = {if (own is Map) ...own, 'tavernState': portable}; + encodedExtra = jsonEncode(extra); + } + snapshots[id] = (physical: physical, extra: encodedExtra); + } + + final root = path.isEmpty ? null : path.first; + final leaf = path.isEmpty ? null : path.last; + final changed = + changedParents.isNotEmpty || + removed.isNotEmpty || + chat['selected_root_id'] != root || + chat['current_leaf_id'] != leaf || + choices.entries.any((entry) => byId[entry.key]!['selected_child_id'] != entry.value); + if (!changed) { + return; + } + + for (final entry in changedParents.entries) { + _db.execute('UPDATE nodes SET parent_id = ? WHERE chat_id = ? AND id = ?', [entry.value, chatId, entry.key]); + } + for (final entry in snapshots.entries) { + _db.execute('UPDATE node_states SET delta_json = ?, full_json = ? WHERE node_id = ?', [ + '[]', + entry.value.physical, + entry.key, + ]); + if (entry.value.extra != null) { + _db.execute('UPDATE nodes SET extra = ? WHERE chat_id = ? AND id = ?', [ + entry.value.extra, + chatId, + entry.key, + ]); + } + } + for (final id in removed) { + _db.execute('DELETE FROM nodes WHERE chat_id = ? AND id = ?', [chatId, id]); + } + for (final id in parents.keys) { + if (byId[id]!['depth'] != depths[id]) { + _db.execute('UPDATE nodes SET depth = ? WHERE chat_id = ? AND id = ?', [depths[id], chatId, id]); + } + if (byId[id]!['selected_child_id'] != choices[id]) { + _db.execute('UPDATE nodes SET selected_child_id = ? WHERE chat_id = ? AND id = ?', [choices[id], chatId, id]); + } + } + _db.execute('UPDATE chats SET selected_root_id = ?, current_leaf_id = ? WHERE id = ?', [root, leaf, chatId]); + _db.execute( + '''DELETE FROM deleted_subtrees WHERE chat_id = ? AND NOT EXISTS + (SELECT 1 FROM nodes n WHERE n.chat_id = deleted_subtrees.chat_id AND n.id = deleted_subtrees.root_node_id)''', + [chatId], + ); + _touch(chatId, tree: true); + }); + } + ({Map state, int deltas}) _nodeStateSnapshot( String chatId, String nodeId, { diff --git a/packages/dsh_tavern/test/p2_script_lifecycle_test.dart b/packages/dsh_tavern/test/p2_script_lifecycle_test.dart new file mode 100644 index 0000000000..d55b967ccd --- /dev/null +++ b/packages/dsh_tavern/test/p2_script_lifecycle_test.dart @@ -0,0 +1,950 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:test/test.dart'; + +import 'p2_script_test_support.dart'; + +void main() { + test('preparation freezes real writes and prompt visibility before nonblocking SENT', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Prepare', greeting: 'G'); + final chat = await h.engine.createChat(character); + final greeting = (await h.engine.path(chat)).single; + await h.engine.setHidden(chat, greeting.nodeId, true); + await h.engine.setPreset(scriptPreset(main: 'PRE {{getvar::phase}}/{{getglobalvar::phase}}')); + final context = await h.context(chat, characterId: character); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(messageId: 0, fields: {'hidden': false}), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.system, message: 'COMMENT MUST STAY OUT', hidden: false), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + final host = ScriptHost(); + final prepared = Completer(); + final release = Completer(); + final ended = Completer(); + final sentStored = Completer(); + final releaseSent = Completer(); + addTearDown(() { + if (!release.isCompleted) { + release.complete(); + } + if (!releaseSent.isCompleted) { + releaseSent.complete(); + } + if (!sentStored.isCompleted) { + sentStored.complete(); + } + }); + host.onDispatch = (request, cancellation) async { + if (request.name == 'GENERATION_AFTER_COMMANDS') { + await h.bridge.replaceVariables(context.id, scriptChat, {'phase': 'LOCAL'}); + await h.bridge.replaceVariables(context.id, scriptGlobal, {'phase': 'GLOBAL'}); + prepared.complete(); + await release.future; + } + if (request.name == 'MESSAGE_SENT') { + await h.bridge.replaceVariables(context.id, scriptGlobal, {'phase': 'AFTER_SENT'}); + if (!sentStored.isCompleted) { + sentStored.complete(); + } + await releaseSent.future; + } + if (request.name == 'GENERATION_ENDED' && !ended.isCompleted) { + ended.complete(); + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final response = h.reply('answer'); + response.beforeResponse = () => sentStored.future; + final events = ScriptEvents(h.engine.send(chat, 'Q')); + await Future.any([ + prepared.future, + events.done.future.then((_) => fail('Turn ended before preparation callback.')), + ]); + expect(h.requests, isEmpty); + expect(h.keyReads, 0); + expect((await h.bridge.getVariables(context.id, scriptChat)).value, {'phase': 'LOCAL'}); + release.complete(); + await events.waitRequest(response); + await events.done.future; + await ended.future; + expect(releaseSent.isCompleted, isFalse); + expect((await h.bridge.getVariables(context.id, scriptGlobal)).value, {'phase': 'AFTER_SENT'}); + releaseSent.complete(); + expect( + h.requests.single.body, + scriptWire([ + {'role': 'system', 'content': 'PRE LOCAL/GLOBAL'}, + {'role': 'assistant', 'content': 'G'}, + {'role': 'user', 'content': 'Q'}, + ]), + ); + expect(h.requests.single.method, 'POST'); + expect(h.requests.single.url, Uri.parse('https://p2-script.invalid/v1/chat/completions')); + expect(events.values.whereType(), isEmpty); + expect(events.values.whereType().single.hookIssues, isEmpty); + expect(h.keyReads, 1); + expect(host.deliveries.where((e) => e.name == 'GENERATION_STARTED'), hasLength(1)); + expect(host.deliveries.where((e) => e.name == 'GENERATION_AFTER_COMMANDS'), hasLength(1)); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_SENT'), hasLength(1)); + expect(host.deliveries.where((e) => e.name == 'GENERATION_ENDED'), hasLength(1)); + stdout.writeln('P2_SCRIPT_LIFECYCLE_READY'); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('received sees committed output and reentrant caches while its derived edit notification waits', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Received'); + final chat = await h.emptyChat(character); + await h.engine.setGlobalVariables({'output': 0}); + await h.engine.setSettings({ + 'globalRegexScripts': [_outputCounter()], + }); + final context = await h.context(chat, characterId: character); + final host = ScriptHost(); + final receivedWritten = Completer(); + final releaseReceived = Completer(); + final receivedReturning = Completer(); + final editedAcked = Completer(); + final ended = Completer(); + final cache = {}; + addTearDown(() { + if (!releaseReceived.isCompleted) { + releaseReceived.complete(); + } + if (!receivedReturning.isCompleted) { + receivedReturning.complete(); + } + }); + host.onDispatch = (request, cancellation) async { + if (request.kind == TavernScriptDispatchKind.cache) { + expect(request.name, isNull); + expect(request.namespace, isNull); + for (final message in request.delta?.upsertMessages ?? []) { + cache[message.ref.nodeId] = message; + } + } else if (request.name == 'MESSAGE_RECEIVED') { + final ref = request.nodeRef!; + expect(cache[ref.nodeId]!.message, '1FINAL'); + final savedText = h.inspect( + (db) => db.select('SELECT text FROM nodes WHERE id = ?', [ref.nodeId]).single['text'], + ); + expect(savedText, '1FINAL'); + expect((await h.engine.inspectTurn(chat, nodeId: ref.nodeId))!.status, TavernInspectionStatus.completed); + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref), + { + 'stat_data': {'hp': 7}, + }, + ); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: ref, fields: {'message': 'HOOK BODY'}), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + expect(cache[ref.nodeId]!.message, 'HOOK BODY'); + expect(cache[ref.nodeId]!.data, { + 'stat_data': {'hp': 7}, + }); + receivedWritten.complete(request); + await releaseReceived.future; + if (!receivedReturning.isCompleted) { + receivedReturning.complete(); + } + } else if (request.name == 'MESSAGE_EDITED') { + // The App may serialize this derived listener after its parent + // RECEIVED while cache acknowledgements remain immediately reentrant. + await receivedReturning.future; + editedAcked.complete(); + } else if (request.name == 'GENERATION_ENDED' && !ended.isCompleted) { + ended.complete(); + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final watched = ScriptEvents(h.bridge.watchEvents(chatId: chat)); + h.reply('RAW'); + final events = ScriptEvents(h.engine.send(chat, 'Q')); + final received = await Future.any([ + receivedWritten.future, + events.done.future.then((_) => throw TestFailure('Missing received hook.')), + ]); + expect(events.values.whereType(), isEmpty); + expect(editedAcked.isCompleted, isFalse); + final busy = await h.engine.send(chat, 'blocked while hook waits').toList(); + expect(busy.whereType().single.kind, TavernFailure.busy); + expect(h.requests, hasLength(1)); + final during = await h.bridge.snapshot(context.id); + expect(during.lastSeq, greaterThanOrEqualTo(received.seq)); + expect(during.messages.last.message, 'HOOK BODY'); + final resumed = ScriptEvents(h.engine.resumeTurn(chat)); + final resumedSnapshot = await resumed.until((event) => event is TavernTurnSnapshot) as TavernTurnSnapshot; + expect(resumedSnapshot.message!.text, 'HOOK BODY'); + expect(resumedSnapshot.message!.nodeId, received.nodeRef!.nodeId); + expect(h.requests, hasLength(1)); + await resumed.detach(); + expect( + host.deliveries.where((e) => e.eventId == received.eventId), + hasLength(1), + reason: 'Taking a snapshot does not recreate or acknowledge pending lifecycle.', + ); + releaseReceived.complete(); + await events.done.future; + await editedAcked.future; + await ended.future; + final done = events.values.whereType().single; + expect(done.message.text, 'HOOK BODY'); + expect(done.hookIssues, isEmpty); + expect(events.values.whereType(), isEmpty); + expect(h.engine.globalVariables['output'], 1, reason: 'Hook edits never replay output regex.'); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_RECEIVED'), hasLength(1)); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_EDITED'), hasLength(1)); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_UPDATED'), isEmpty); + await watched.until((event) => event.dispatch.eventId == received.eventId && event.phase == 'acknowledged'); + expect( + h.requests.single.body, + scriptWire([ + {'role': 'user', 'content': 'Q'}, + ]), + ); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + expect((await h.engine.path(chat)).last.text, 'HOOK BODY'); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value, { + 'stat_data': {'hp': 7}, + }); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('hook and model failures retain distinct terminal payloads and committed effects', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Hook failure'); + final chat = await h.emptyChat(character); + await h.engine.setGlobalVariables({'output': 0}); + await h.engine.setSettings({ + 'globalRegexScripts': [_outputCounter()], + }); + final context = await h.context(chat, characterId: character); + final host = ScriptHost(); + host.onDispatch = (request, cancellation) async { + if (request.name == 'MESSAGE_RECEIVED') { + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: request.nodeRef), + {'kept': true}, + ); + return TavernScriptDispatchAck( + eventId: request.eventId, + status: TavernScriptAckStatus.failed, + issues: [ + TavernScriptIssue( + code: 'fixtureHook', + message: 'Synthetic host handler failure', + committed: true, + eventId: request.eventId, + nodeRef: request.nodeRef, + ), + ], + ); + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final watched = ScriptEvents(h.bridge.watchEvents(chatId: chat)); + h.reply('RAW'); + final events = await h.engine.send(chat, 'Q').toList(); + final done = events.whereType().single; + expect(done.message.text, '1FINAL'); + expect(events.whereType(), isEmpty); + expect(done.hookIssues.map((issue) => issue.code), contains('fixtureHook')); + expect(done.hookIssues.every((issue) => issue.committed), isTrue); + expect(h.engine.globalVariables['output'], 1); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, {'kept': true}); + expect((await h.engine.inspectTurn(chat, nodeId: done.message.nodeId))!.status, TavernInspectionStatus.completed); + final failure = await watched.until((event) => event.name == 'MESSAGE_RECEIVED' && event.phase == 'failed'); + expect(failure.committed, isTrue); + expect(failure.issues.map((issue) => issue.code), contains('fixtureHook')); + final hookEnd = await watched.until((event) => event.name == 'GENERATION_ENDED' && event.phase == 'acknowledged'); + final hookPayload = scriptMap(hookEnd.dispatch.args.single); + expect(hookPayload['status'], 'completed'); + expect(hookPayload['committed'], isTrue); + expect(hookPayload['modelFailure'], isNull); + expect(hookPayload['replyRef'], {'chatId': chat, 'nodeId': done.message.nodeId}); + expect(hookPayload['partialRef'], isNull); + expect( + (hookPayload['hookIssues']! as List).map((issue) => scriptMap(issue)['code']), + contains('fixtureHook'), + ); + h.reply('denied', status: 401); + final failedTurn = await h.engine.send(chat, 'failed Q').toList(); + expect(failedTurn.whereType(), isEmpty); + final modelFailure = failedTurn.whereType().single; + expect(modelFailure.kind, TavernFailure.badKey); + expect(modelFailure.partial, isNull); + final failedEnd = await watched.until( + (event) => + event.name == 'GENERATION_ENDED' && + event.phase == 'acknowledged' && + event.dispatch.generationId != hookEnd.dispatch.generationId, + ); + final failedPayload = scriptMap(failedEnd.dispatch.args.single); + expect(failedPayload['status'], 'failed'); + expect(failedPayload['committed'], isFalse); + expect(failedPayload['modelFailure'], 'badKey'); + expect(failedPayload['replyRef'], isNull); + expect(failedPayload['partialRef'], isNull); + expect( + host.deliveries.where( + (event) => event.name == 'MESSAGE_RECEIVED' && event.generationId == failedEnd.dispatch.generationId, + ), + isEmpty, + ); + expect((await h.engine.path(chat)).map((message) => message.text), ['Q', '1FINAL']); + expect(h.engine.globalVariables['output'], 1); + await h.reopen(); + expect((await h.engine.path(chat)).last.text, '1FINAL'); + final fresh = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value, {'kept': true}); + expect(h.requests, hasLength(2)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('detaching a host releases an unacknowledged hook and ignores its late acknowledgement', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Detach'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final host = ScriptHost(); + final received = Completer<(TavernScriptDispatch, TavernScriptCancellation)>(); + final lateAck = Completer(); + addTearDown(() { + if (!lateAck.isCompleted && received.isCompleted) { + lateAck.complete(TavernScriptDispatchAck(eventId: 'late-cleanup')); + } + }); + host.onDispatch = (request, cancellation) async { + if (request.name == 'MESSAGE_RECEIVED') { + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: request.nodeRef), + {'beforeDetach': true}, + ); + received.complete((request, cancellation)); + return lateAck.future; + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final watched = ScriptEvents(h.bridge.watchEvents(chatId: chat)); + h.reply('first'); + final turn = ScriptEvents(h.engine.send(chat, 'Q')); + final held = await Future.any([ + received.future, + turn.done.future.then<(TavernScriptDispatch, TavernScriptCancellation)>( + (_) => throw TestFailure('Turn did not reach held received hook.'), + ), + ]); + h.bridge.attachHost(null); + await held.$2.whenCancelled; + await turn.done.future; + final done = turn.values.whereType().single; + expect(done.message.text, 'first'); + expect(done.hookIssues.any((issue) => issue.code == 'hostUnavailable'), isTrue); + final failed = await watched.until((event) => event.dispatch.eventId == held.$1.eventId && event.phase == 'failed'); + expect(failed.committed, isTrue); + lateAck.complete(TavernScriptDispatchAck(eventId: held.$1.eventId)); + h.reply('second'); + final next = await h.engine.send(chat, 'next').toList(); + expect(next.whereType().single.message.text, 'second'); + expect(watched.values.where((event) => event.dispatch.eventId == held.$1.eventId).map((event) => event.phase), [ + 'published', + 'failed', + ]); + expect( + (await h.bridge.getVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: held.$1.nodeRef), + )).value, + {'beforeDetach': true}, + ); + expect(h.requests, hasLength(2)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('snapshot recovery preserves deltas and resource cancellation releases its carrier waits', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Cache recovery'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final host = ScriptHost(); + final recovered = []; + final firstCache = Completer(); + final releaseFirstCache = Completer(); + final globalCache = Completer(); + addTearDown(() { + if (!releaseFirstCache.isCompleted) { + releaseFirstCache.complete(); + } + }); + host.onDispatch = (request, cancellation) async { + if (request.kind == TavernScriptDispatchKind.cache) { + final snapshot = await h.bridge.snapshot(context.id); + expect(snapshot.lastSeq, greaterThanOrEqualTo(request.seq)); + recovered.add(snapshot); + final delta = request.delta!; + if (!firstCache.isCompleted && delta.upsertMessages.any((message) => message.message == 'recover me')) { + firstCache.complete(); + await releaseFirstCache.future; + } + if (!globalCache.isCompleted && + delta.variableSnapshots.any( + (value) => + value.resolvedTarget.scope == TavernVariableScope.global && value.value['afterPull'] == 'committed', + )) { + globalCache.complete(request); + } + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final create = h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.assistant, message: 'recover me', data: {'value': 1}), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + await Future.any([ + firstCache.future, + create.then((_) => throw StateError('The message write completed before its held cache delivery.')), + ]); + // A real carrier commit occurs while the earlier cache Ack is held. A pull + // must see it without moving the baseline used by the still-pending delta. + await h.engine.setGlobalVariables({'afterPull': 'committed'}); + final pulled = await h.bridge.snapshot(context.id); + expect( + pulled.variableSnapshots.singleWhere((value) => value.resolvedTarget.scope == TavernVariableScope.global).value, + {'afterPull': 'committed'}, + ); + releaseFirstCache.complete(); + final created = await create; + final deliveredGlobal = await globalCache.future; + expect( + deliveredGlobal.delta!.variableSnapshots + .singleWhere((value) => value.resolvedTarget.scope == TavernVariableScope.global) + .value, + {'afterPull': 'committed'}, + ); + expect(created.upsertMessages.single.message, 'recover me'); + expect( + recovered.any((snapshot) => snapshot.messages.length == 1 && snapshot.messages.single.message == 'recover me'), + isTrue, + ); + expect(host.deliveries.where((request) => request.name != null), isEmpty); + h.expectNoGeneration(); + + // listen() registers the native turn synchronously. Its first view awaits + // the global carrier before it can enqueue the character/preset read. + // The real resource mutation below therefore owns its resource queue + // while cancelling and draining that turn. No host or file fake unlocks it. + h.bridge.attachHost(null); + final deletedCharacter = await h.card('Delete during script view'); + final deletedChat = await h.emptyChat(deletedCharacter); + final deletedContext = await h.context( + deletedChat, + characterId: deletedCharacter, + instanceId: 'delete-while-reading', + ); + final deletingTurn = h.engine.send(deletedChat, 'must not reach a key').toList(); + final deleting = h.engine.deleteCharacter(deletedCharacter); + await deleting; + final deletedEvents = await deletingTurn; + expect(deletedEvents.whereType().single.kind, TavernFailure.cancelled); + expect(deletedEvents.whereType(), isEmpty); + expect(await h.engine.character(deletedCharacter), isNull); + await h.bridge.closeContext(deletedContext.id); + + final presetRaw = scriptPreset().raw + ..['name'] = 'Preset carrier cancellation' + ..['extensions'] = {'tavern_helper': scriptContainer('P')}; + final preset = (await h.engine.importPreset(jsonEncode(presetRaw))).preset.id; + final presetChat = await h.emptyChat(character); + await h.engine.bindPreset(presetChat, preset); + final presetContext = await h.context( + presetChat, + owner: TavernScriptOwner(kind: TavernScriptOwnerKind.preset, assetId: preset, scriptId: 'same-script'), + instanceId: 'bind-while-reading', + ); + final bindingTurn = h.engine.send(presetChat, 'must not reach another key').toList(); + final binding = h.engine.bindPreset(presetChat, null); + await binding; + final boundEvents = await bindingTurn; + expect(boundEvents.whereType().single.kind, TavernFailure.cancelled); + expect(boundEvents.whereType(), isEmpty); + expect((await h.engine.presetSelection(chatId: presetChat)).boundPresetId, isNull); + expect(await h.engine.path(presetChat), isEmpty); + await h.bridge.closeContext(presetContext.id); + h.expectNoGeneration(); + await h.reopen(); + expect(await h.engine.character(deletedCharacter), isNull); + expect((await h.engine.presetSelection(chatId: presetChat)).boundPresetId, isNull); + expect(await h.engine.path(presetChat), isEmpty); + h.expectNoGeneration(); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('group B reads A hook writes and final story settlement preserves B hook body and data', () async { + final h = await ScriptFixture.open(allowModel: true); + final owner = await h.card( + 'Host', + extensions: { + 'dshgo': { + 'v': 1, + 'story': { + 'acts': [ + {'id': 'waiting', 'title': 'WAIT', 'when': false}, + ], + }, + }, + }, + ); + final a = await h.card('A'); + final b = await h.card('B'); + final chat = await h.engine.createGroupChat( + [a, b], + hostCharacterId: owner, + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + await h.engine.setSettings({ + 'director': {'enabled': true, 'every': 2}, + }, chatId: chat); + await h.engine.setPreset(scriptPreset(main: 'PHASE {{getvar::phase}}')); + final context = await h.context(chat, characterId: owner); + await h.bridge.replaceVariables(context.id, scriptChat, {'phase': 'INIT'}); + final host = ScriptHost(); + final receivedRefs = []; + host.onDispatch = (request, cancellation) async { + if (request.name == 'MESSAGE_RECEIVED') { + final ref = request.nodeRef!; + final message = (await h.engine.path(chat)).singleWhere((m) => m.nodeId == ref.nodeId); + receivedRefs.add(ref); + if (message.speakerCharacterId == a) { + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref), + { + 'stat_data': {'hp': 7}, + }, + ); + await h.bridge.replaceVariables(context.id, scriptChat, {'phase': 'AFTER_A'}); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: ref, fields: {'message': 'A HOOK'}), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + } else if (message.speakerCharacterId == b) { + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref), + { + 'stat_data': {'hp': 9}, + }, + ); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + ref: ref, + fields: { + 'message': 'B HOOK', + 'extra': {'callback': 'B'}, + }, + ), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + } + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + h.reply('A RAW'); + final second = h.reply('B RAW'); + second.beforeResponse = () async { + final rows = h.inspect( + (db) => db + .select('SELECT text FROM nodes WHERE chat_id = ? AND speaker_character_id = ?', [chat, a]) + .map((row) => row['text']) + .toList(), + ); + expect(rows, ['A HOOK']); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, { + 'stat_data': {'hp': 7}, + }); + expect((await h.engine.readDirector(chat)).story!['initialized'], isFalse); + }; + final events = await h.engine.sendGroup(chat, 'Q').toList(); + expect(events.whereType().single.reason, TavernGroupEndReason.completed); + final done = events.whereType().map((e) => e.event).whereType().toList(); + expect(done.map((e) => e.message.text), ['A HOOK', 'B HOOK']); + expect(done.every((e) => e.hookIssues.isEmpty), isTrue); + expect(h.requests.map((r) => r.body), [ + scriptWire([ + {'role': 'system', 'content': 'PHASE INIT'}, + {'role': 'user', 'content': 'Q'}, + ]), + scriptWire([ + {'role': 'system', 'content': 'PHASE AFTER_A'}, + {'role': 'user', 'content': 'Q'}, + {'role': 'assistant', 'content': 'A: A HOOK'}, + ]), + ]); + expect((await h.engine.path(chat)).map((m) => m.text), ['Q', 'A HOOK', 'B HOOK']); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, { + 'stat_data': {'hp': 9}, + }); + expect( + (await h.bridge.getChatMessages(context.id, const TavernMessageRange())).messages.last.extra['callback'], + 'B', + ); + expect( + (await h.bridge.getVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: receivedRefs.first), + )).value, + { + 'stat_data': {'hp': 7}, + }, + ); + final runs = await h.engine.readAgentRuns(chat, limit: 1000); + expect(runs.where((run) => run.kind == 'story.rules'), hasLength(1)); + expect((await h.engine.readDirector(chat)).story!['actTurns'], 1); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_RECEIVED'), hasLength(2)); + await h.reopen(); + final fresh = await h.context(chat, characterId: owner); + expect((await h.engine.path(chat)).map((m) => m.text), ['Q', 'A HOOK', 'B HOOK']); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value, { + 'stat_data': {'hp': 9}, + }); + expect((await h.bridge.getChatMessages(fresh.id, const TavernMessageRange())).messages.last.extra['callback'], 'B'); + expect((await h.engine.readDirector(chat)).story!['actTurns'], 1); + expect(h.requests, hasLength(2)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('an ended handler may await the next main generation with a distinct lifecycle identity', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Chain'); + final chat = await h.emptyChat(character); + await h.context(chat, characterId: character); + final host = ScriptHost(); + final chained = Completer>(); + final secondEnded = Completer(); + var startedNext = false; + host.onDispatch = (request, cancellation) async { + if (request.name == 'GENERATION_ENDED') { + if (!startedNext) { + startedNext = true; + final next = await h.engine.send(chat, 'Q2').toList(); + chained.complete(next); + } else { + secondEnded.complete(); + } + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + h.reply('first'); + h.reply('second'); + final first = await h.engine.send(chat, 'Q1').toList(); + expect(first.whereType().single.message.text, 'first'); + final next = await chained.future; + await secondEnded.future; + expect(next.whereType(), isEmpty); + expect(next.whereType().single.message.text, 'second'); + final starts = host.deliveries.where((e) => e.name == 'GENERATION_STARTED').toList(); + expect(starts, hasLength(2)); + expect(starts.map((e) => e.generationId).toSet(), hasLength(2)); + expect(starts.every((e) => e.generationId != null), isTrue); + expect(host.deliveries.where((e) => e.name == 'GENERATION_ENDED'), hasLength(2)); + expect(h.requests.map((r) => r.body), [ + scriptWire([ + {'role': 'user', 'content': 'Q1'}, + ]), + scriptWire([ + {'role': 'user', 'content': 'Q1'}, + {'role': 'assistant', 'content': 'first'}, + {'role': 'user', 'content': 'Q2'}, + ]), + ]); + expect((await h.engine.path(chat)).map((m) => m.text), ['Q1', 'first', 'Q2', 'second']); + }, timeout: const Timeout(Duration(seconds: 15))); + + test( + 'a structure mutation from received cancels its outer group without waiting on its own acknowledgement', + () async { + final h = await ScriptFixture.open(allowModel: true); + final a = await h.card('A'); + final b = await h.card('B'); + final chat = await h.engine.createGroupChat( + [a, b], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final context = await h.context(chat, characterId: a); + final host = ScriptHost(); + final mutationDone = Completer(); + var invoked = false; + host.onDispatch = (request, cancellation) async { + if (request.name == 'MESSAGE_RECEIVED' && !invoked) { + invoked = true; + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: request.nodeRef), + {'beforeDelete': true}, + ); + final deleted = await h.bridge.deleteChatMessages(context.id, [ + 0, + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + mutationDone.complete(deleted); + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final watched = ScriptEvents(h.bridge.watchEvents(chatId: chat)); + h.reply('A complete'); + h.reply('B must not be requested'); + final events = await h.engine.sendGroup(chat, 'one user').toList(); + final mutation = await mutationDone.future; + expect(events.whereType().single.reason, TavernGroupEndReason.cancelled); + expect(mutation.removedRefs, hasLength(1)); + expect((await h.engine.path(chat)).map((m) => m.text), ['A complete']); + expect((await h.engine.path(chat)).single.parentId, isNull); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, {'beforeDelete': true}); + final stopped = await watched.until( + (event) => event.name == 'GENERATION_STOPPED' && event.phase == 'acknowledged', + ); + final stoppedPayload = scriptMap(stopped.dispatch.args.single); + expect(stoppedPayload['status'], 'completed'); + expect(stoppedPayload['cancelled'], isTrue); + expect(stoppedPayload['committed'], isTrue); + expect(stoppedPayload['modelFailure'], isNull); + expect(stoppedPayload['replyRef'], {'chatId': chat, 'nodeId': (await h.engine.path(chat)).single.nodeId}); + expect(stoppedPayload['partialRef'], isNull); + expect(h.requests, hasLength(1)); + expect(h.keyReads, 1); + await h.reopen(); + expect((await h.engine.path(chat)).single.text, 'A complete'); + }, + timeout: const Timeout(Duration(seconds: 15)), + ); + + test('actor ended remains owned by the outer group when its handler changes structure', () async { + final h = await ScriptFixture.open(allowModel: true); + final a = await h.card('A'); + final b = await h.card('B'); + final chat = await h.engine.createGroupChat( + [a, b], + activation: TavernGroupActivation.listOrder, + addGreetings: false, + ); + final context = await h.context(chat, characterId: a); + final host = ScriptHost(); + final structureAdmitted = Completer(); + final structureDone = Completer(); + addTearDown(() { + if (!structureAdmitted.isCompleted) { + structureAdmitted.complete(); + } + }); + var starts = 0; + var changed = false; + host.onDispatch = (request, cancellation) async { + if (request.name == 'GENERATION_STARTED') { + starts++; + if (starts > 1) { + await structureAdmitted.future; + } + } else if (request.name == 'GENERATION_ENDED' && !changed) { + changed = true; + final writing = h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.system, message: 'STRUCTURE'), + ], options: const TavernScriptWriteOptions(refresh: TavernScriptRefresh.none)); + structureAdmitted.complete(); + structureDone.complete(await writing); + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + h.reply('A complete'); + h.reply('B must not be requested'); + final events = await h.engine.sendGroup(chat, 'Q').toList(); + await structureDone.future; + expect(events.whereType().single.reason, TavernGroupEndReason.cancelled); + expect((await h.engine.path(chat)).map((m) => m.text), ['Q', 'A complete', 'STRUCTURE']); + expect(h.requests, hasLength(1)); + expect(h.keyReads, 1); + expect((await h.engine.groupChat(chat))!.members.map((m) => m.characterId), [a, b]); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('engine close cancels host waits and preserves commits without waiting for arbitrary host code', () async { + final h = await ScriptFixture.open(allowModel: true); + final character = await h.card('Close'); + final chat = await h.emptyChat(character); + await h.context(chat, characterId: character); + final host = ScriptHost(); + final entered = Completer(); + final late = Completer(); + addTearDown(() { + if (!late.isCompleted) { + late.complete(TavernScriptDispatchAck(eventId: 'cleanup')); + } + }); + host.onDispatch = (request, cancellation) async { + if (request.name == 'MESSAGE_RECEIVED') { + entered.complete(request); + return late.future; + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + h.reply('kept'); + final turn = ScriptEvents(h.engine.send(chat, 'Q')); + final received = await Future.any([ + entered.future, + turn.done.future.then((_) => throw TestFailure('Missing hook before close.')), + ]); + await h.close(); + await turn.done.future; + late.complete(TavernScriptDispatchAck(eventId: received.eventId)); + await h.openEngine(); + expect((await h.engine.path(chat)).map((m) => m.text), ['Q', 'kept']); + expect(turn.values.whereType().single.hookIssues, isNotEmpty); + expect(h.requests, hasLength(1)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('raw CRUD maps its own events without fabricating sent received or rendered events', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Events'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final host = ScriptHost(); + h.bridge.attachHost(host); + final watched = ScriptEvents(h.bridge.watchEvents(chatId: chat)); + const quiet = TavernScriptWriteOptions(refresh: TavernScriptRefresh.none); + final made = await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.assistant, message: 'raw', data: {'v': 0}), + ], options: quiet); + final ref = made.upsertMessages.single.ref; + await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref), + {'v': 1}, + ); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: ref, fields: {'message': 'edited'}), + ], options: quiet); + await watched.until((e) => e.name == 'MESSAGE_EDITED' && e.phase == 'acknowledged'); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: ref, fields: {'name': 'New name'}), + ], options: quiet); + await watched.until((e) => e.name == 'MESSAGE_UPDATED' && e.phase == 'acknowledged'); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + ref: ref, + fields: { + 'swipes': ['edited', 'alternate'], + }, + ), + ], options: quiet); + final switched = await h.bridge.selectSwipe(context.id, ref, 1, options: quiet); + final swipeEvent = await watched.until((e) => e.name == 'MESSAGE_SWIPED' && e.phase == 'acknowledged'); + expect(swipeEvent.dispatch.nodeRef, switched.upsertMessages.last.ref); + final deleted = await h.bridge.deleteChatMessages(context.id, [0], options: quiet); + expect(deleted.removedRefs, hasLength(2)); + await watched.until((e) => e.name == 'MESSAGE_DELETED' && e.phase == 'acknowledged'); + expect( + host.deliveries.where( + (e) => e.name == 'MESSAGE_SENT' || e.name == 'MESSAGE_RECEIVED' || e.name?.endsWith('_RENDERED') == true, + ), + isEmpty, + ); + expect(host.refreshes, isEmpty); + final sent = await h.engine.sendOnly(chat, 'manual'); + final narrated = await h.engine.narrate(chat, 'narrated'); + await watched.until( + (e) => e.name == 'MESSAGE_SENT' && e.phase == 'acknowledged' && e.dispatch.nodeRef?.nodeId == narrated.nodeId, + ); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_SENT').map((e) => e.nodeRef?.nodeId), [ + sent.nodeId, + narrated.nodeId, + ]); + expect(host.deliveries.where((e) => e.name == 'MESSAGE_RECEIVED'), isEmpty); + final cacheOnly = host.deliveries.where((e) => e.kind == TavernScriptDispatchKind.cache); + expect(cacheOnly.every((e) => e.name == null && e.namespace == null), isTrue); + final seq = host.deliveries.map((e) => e.seq).toList(); + expect(seq.toSet().length, seq.length); + expect(host.deliveries.map((e) => e.eventId).toSet().length, host.deliveries.length); + h.expectNoGeneration(); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('preparation cancellation preserves committed handler variables and the next native macro view', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Preparation stop'); + final chat = await h.emptyChat(character); + final prior = await h.engine.sendOnly(chat, 'prior'); + final context = await h.context(chat, characterId: character); + await h.bridge.replaceVariables(context.id, scriptChat, {'before': 3}); + final prepared = Completer(); + final release = Completer(); + addTearDown(() { + if (!release.isCompleted) { + release.complete(); + } + }); + final host = ScriptHost(); + host.onDispatch = (request, cancellation) async { + if (request.name == 'GENERATION_AFTER_COMMANDS') { + await h.bridge.replaceVariables(context.id, scriptChat, {'kept': 7}); + await h.bridge.replaceVariables(context.id, scriptGlobal, {'prepGlobal': 'kept'}); + prepared.complete(); + await release.future; + } + return TavernScriptDispatchAck(eventId: request.eventId); + }; + h.bridge.attachHost(host); + final turn = ScriptEvents(h.engine.send(chat, 'must not be appended')); + await Future.any([ + prepared.future, + turn.done.future.then((_) => throw StateError('Turn ended before its preparation write.')), + ]); + expect((await h.bridge.getVariables(context.id, scriptChat)).value, {'kept': 7}); + h.expectNoGeneration(); + h.engine.stop(chat); + await turn.done.future; + expect(release.isCompleted, isFalse); + final failure = turn.values.whereType().single; + expect(failure.kind, TavernFailure.cancelled); + expect(failure.partial, isNull); + expect(turn.values.whereType(), isEmpty); + expect((await h.engine.path(chat)).map((message) => message.nodeId), [prior.nodeId]); + expect((await h.bridge.getVariables(context.id, scriptChat)).value, {'kept': 7}); + expect((await h.bridge.getVariables(context.id, scriptGlobal)).value, {'prepGlobal': 'kept'}); + h.bridge.attachHost(null); + release.complete(); + final macro = await h.engine.sendOnly(chat, 'LOCAL {{getvar::kept}}'); + expect(macro.text, 'LOCAL 7'); + h.expectNoGeneration(); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(fresh.id, scriptChat)).value, {'kept': 7}); + expect((await h.bridge.getVariables(fresh.id, scriptGlobal)).value, {'prepGlobal': 'kept'}); + expect((await h.engine.path(chat)).map((message) => message.text), ['prior', 'LOCAL 7']); + h.expectNoGeneration(); + }, timeout: const Timeout(Duration(seconds: 15))); +} + +ScriptJson _outputCounter() => { + 'id': 'output-counter', + 'scriptName': 'output-counter', + 'findRegex': r'^RAW$', + 'replaceString': '{{incglobalvar::output}}FINAL', + 'trimStrings': [], + 'placement': [2], + 'disabled': false, + 'markdownOnly': false, + 'promptOnly': false, + 'runOnEdit': false, + 'substituteRegex': 0, + 'minDepth': null, + 'maxDepth': null, +}; diff --git a/packages/dsh_tavern/test/p2_script_messages_test.dart b/packages/dsh_tavern/test/p2_script_messages_test.dart new file mode 100644 index 0000000000..2834ae2f5f --- /dev/null +++ b/packages/dsh_tavern/test/p2_script_messages_test.dart @@ -0,0 +1,789 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:test/test.dart'; + +import 'p2_script_test_support.dart'; + +void main() { + test('bridge numbers the complete path before range and role filters', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Long path'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.createChatMessages(context.id, [ + for (var index = 0; index < 70; index++) + TavernScriptMessageDraft( + role: TavernScriptRole.values[index % 3], + message: 'line-$index', + hidden: index % 5 == 0, + data: {'index': index}, + ), + ]); + expect(await h.engine.path(chat), hasLength(60)); + final all = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + expect(all.totalPathLength, 70); + expect(all.messages.map((message) => message.messageId), List.generate(70, (i) => i)); + expect(all.messages.map((message) => message.message), List.generate(70, (i) => 'line-$i')); + expect(all.messages.every((message) => message.swipes.isEmpty), isTrue); + final tail = await h.bridge.getChatMessages( + context.id, + const TavernMessageRange(first: -3, last: -1), + includeSwipes: true, + ); + expect(tail.messages.map((message) => message.messageId), [67, 68, 69]); + expect(tail.messages.every((message) => message.swipes.length == 1), isTrue); + final intersection = await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -100, last: 2)); + expect(intersection.messages.map((message) => message.messageId), [0, 1, 2]); + expect( + (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: 90, last: 99))).messages, + isEmpty, + ); + final filtered = await h.bridge.getChatMessages( + context.id, + const TavernMessageRange(), + role: TavernScriptRole.system, + hidden: TavernHiddenFilter.hiddenOnly, + ); + expect(filtered.messages.map((message) => message.messageId), [5, 20, 35, 50, 65]); + final anotherChat = await h.emptyChat(character); + final other = await h.context(anotherChat, characterId: character, instanceId: 'other-view'); + await h.bridge.activateChat(other.id); + final snapshot = await h.bridge.snapshot(context.id); + expect(snapshot.context.chatId, chat); + expect(snapshot.messages, hasLength(70)); + expect(snapshot.pathRevision, all.pathRevision); + expect(snapshot.capabilities.scopes, TavernVariableScope.values.toSet()); + expect(snapshot.capabilities.generationOptions, isEmpty); + expect(snapshot.capabilities.slashCommands, isEmpty); + await h.bridge.closeContext(context.id); + await expectLater(() => h.bridge.snapshot(context.id), throwsA(scriptError(TavernScriptErrorCode.closedContext))); + expect((await h.bridge.snapshot(other.id)).messages, isEmpty); + h.expectNoGeneration(); + stdout.writeln('P2_SCRIPT_MESSAGES_READY'); + }); + + test('raw message writes retain literal text and preserve private extra and narrator identity', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Raw'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.engine.setSettings({ + 'globalRegexScripts': [ + { + 'id': 'input', + 'scriptName': 'input', + 'findRegex': 'RAW', + 'replaceString': 'REWRITTEN', + 'trimStrings': [], + 'placement': [1, 2, 3], + 'disabled': false, + 'markdownOnly': false, + 'promptOnly': false, + 'runOnEdit': true, + 'substituteRegex': 0, + 'minDepth': null, + 'maxDepth': null, + }, + ], + }); + expect((await h.engine.sendOnly(chat, 'RAW')).text, 'REWRITTEN'); + const literal = ' RAW {{incvar::must-not-run}} '; + final created = await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft( + role: TavernScriptRole.assistant, + message: literal, + name: '', + speakerCharacterId: character, + data: {'opaque': true}, + extra: { + 'foreign': [1, null], + 'type': 'narrator', + }, + ), + ]); + final ref = created.upsertMessages.last.ref; + expect((await h.engine.path(chat)).last.text, literal); + expect((await h.engine.path(chat)).last.role, TavernRole.character); + expect((await h.engine.readVariables(chat)).containsKey('must-not-run'), isFalse); + await h.engine.nameBranch(chat, ref.nodeId, 'PRIVATE NAME'); + final privateBefore = _privateExtra(h, ref); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: ref, fields: {'message': '${literal}RAW', 'extra': {}}), + ]); + expect(_privateExtra(h, ref), privateBefore); + final actual = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single; + expect(actual.extra, isEmpty); + expect(actual.message, '${literal}RAW'); + expect(actual.speakerCharacterId, character); + final before = (await h.bridge.getChatMessages(context.id, const TavernMessageRange())).pathRevision; + for (final forbidden in [ + { + 'extra': { + 'dshgo': {'tavernState': {}}, + }, + }, + { + 'swipesInfo': [ + { + 'extra': { + 'dshgo': {'branchName': 'bypass'}, + }, + }, + ], + }, + ]) { + await expectLater( + () => h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(messageId: 0, fields: {'message': 'must roll back'}), + TavernScriptMessagePatch(ref: ref, fields: forbidden), + ]), + throwsA(scriptError(TavernScriptErrorCode.reservedField)), + ); + expect((await h.engine.path(chat)).first.text, 'REWRITTEN'); + expect((await h.bridge.getChatMessages(context.id, const TavernMessageRange())).pathRevision, before); + expect(_privateExtra(h, ref), privateBefore); + } + final narrator = await h.engine.narrate(chat, 'narration', speaker: 'Narrator'); + final narratorRef = TavernScriptMessageRef(chatId: chat, nodeId: narrator.nodeId); + final projected = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single; + expect(projected.role, TavernScriptRole.system); + expect(projected.nativeRole, TavernRole.narrator); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: narratorRef, fields: {'role': 'system', 'message': 'retained narrator'}), + ]); + expect((await h.engine.path(chat)).last.role, TavernRole.narrator); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(ref: narratorRef, fields: {'role': 'user'}), + TavernScriptMessagePatch(ref: narratorRef, fields: {'role': 'system'}), + ]); + expect((await h.engine.path(chat)).last.role, TavernRole.system); + final exported = (await h.engine.exportChat(chat)).first; + final document = StChatDocument.decode(exported.fileName, exported.bytes); + expect(document.messages.last['is_system'], isTrue); + await h.reopen(); + expect((await h.engine.path(chat)).last.role, TavernRole.system); + expect(_privateExtra(h, ref), privateBefore); + h.expectNoGeneration(); + }); + + test('public JSONL round trip preserves authoritative roles and actor IDs with narrator-shaped extra', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Role round trip'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft( + role: TavernScriptRole.user, + message: 'USER', + extra: {'type': 'narrator', 'foreign': 'user'}, + ), + TavernScriptMessageDraft( + role: TavernScriptRole.assistant, + message: 'ACTOR-0', + speakerCharacterId: character, + extra: {'type': 'narrator'}, + ), + ]); + final actor = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single; + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + ref: actor.ref, + fields: { + 'swipes': ['ACTOR-0', 'ACTOR-1'], + 'swipeId': 1, + 'swipesInfo': [ + { + 'extra': {'type': 'narrator', 'page': 0}, + }, + { + 'extra': {'type': 'narrator', 'page': 1}, + }, + ], + }, + ), + ]); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft( + role: TavernScriptRole.system, + message: 'COMMENT', + hidden: false, + extra: {'type': 'narrator', 'foreign': 'comment'}, + ), + ]); + await h.engine.narrate(chat, 'NARRATOR', speaker: 'Narrator'); + final expectedRoles = [TavernRole.user, TavernRole.character, TavernRole.system, TavernRole.narrator]; + final expectedIds = [null, character, null, null]; + expect((await h.engine.path(chat)).map((message) => message.role), expectedRoles); + expect((await h.engine.path(chat)).map((message) => message.speakerCharacterId), expectedIds); + ScriptJson own(Object? extra) => scriptMap(scriptMap(scriptMap(extra)['dshgo'])['chatJsonl']); + void checkWire(List files) { + final document = StChatDocument.decode(files.first.fileName, files.first.bytes); + final row = document.messages.singleWhere((message) => message['mes'] == 'ACTOR-1'); + expect(scriptMap(row['extra'])['type'], 'narrator'); + expect(own(row['extra'])['role'], 'character'); + expect(own(row['extra'])['speakerCharacterId'], character); + final infos = (row['swipe_info']! as List).map(scriptMap).toList(); + expect(infos.map((info) => own(info['extra'])['role']), ['character', 'character']); + expect(infos.map((info) => own(info['extra'])['speakerCharacterId']), [character, character]); + expect(infos.map((info) => scriptMap(info['extra'])['type']), ['narrator', 'narrator']); + expect(infos.map((info) => scriptMap(info['extra'])['page']), [0, 1]); + final comment = document.messages.singleWhere((message) => message['mes'] == 'COMMENT'); + expect(scriptMap(comment['extra'])['type'], 'narrator'); + expect(own(comment['extra'])['role'], 'system'); + expect(own(comment['extra'])['hidden'], isFalse); + expect(comment['is_system'], isTrue); + } + + final files = await h.engine.exportChat(chat); + checkWire(files); + final imported = await h.engine.importChats(character, files); + final copy = imported.chatIds.single; + expect(copy, isNot(chat)); + Future checkStored() async { + final path = await h.engine.path(copy); + expect(path.map((message) => message.text), ['USER', 'ACTOR-1', 'COMMENT', 'NARRATOR']); + expect(path.map((message) => message.role), expectedRoles); + expect(path.map((message) => message.speakerCharacterId), expectedIds); + expect(path.map((message) => message.hidden), [false, false, false, false]); + final pages = (await h.engine.tree( + copy, + )).where((node) => node.preview == 'ACTOR-0' || node.preview == 'ACTOR-1').toList(); + expect(pages, hasLength(2)); + expect(pages.map((node) => node.role), everyElement(TavernRole.character)); + expect(pages.map((node) => node.speakerCharacterId), everyElement(character)); + expect(pages.singleWhere((node) => node.preview == 'ACTOR-0').isCurrentPath, isFalse); + expect(pages.singleWhere((node) => node.preview == 'ACTOR-1').isCurrentPath, isTrue); + } + + await checkStored(); + final copiedContext = await h.context(copy, characterId: character, instanceId: 'copied-role-view'); + final copiedView = await h.bridge.getChatMessages( + copiedContext.id, + const TavernMessageRange(), + includeSwipes: true, + ); + expect(copiedView.messages.map((message) => message.nativeRole), expectedRoles); + expect(copiedView.messages[1].extra['type'], 'narrator'); + expect(copiedView.messages[2].extra['type'], 'narrator'); + expect(copiedView.messages[1].swipes.map((swipe) => swipe.text), ['ACTOR-0', 'ACTOR-1']); + await h.reopen(); + await checkStored(); + checkWire(await h.engine.exportChat(copy)); + final legacy = TavernChatFile( + fileName: 'legacy-role.jsonl', + bytes: Uint8List.fromList( + utf8.encode( + [ + {'user_name': 'User', 'character_name': 'Role round trip', 'chat_metadata': {}}, + { + 'name': 'Narrator', + 'mes': 'LEGACY-NARRATOR', + 'is_user': false, + 'is_system': false, + 'extra': {'type': 'narrator'}, + }, + { + 'name': 'User', + 'mes': 'LEGACY-USER', + 'is_user': true, + 'is_system': false, + 'extra': {'type': 'narrator'}, + }, + ].map(jsonEncode).join('\n'), + ), + ), + ); + final legacyChat = (await h.engine.importChats(character, [legacy])).chatIds.single; + final legacyPath = await h.engine.path(legacyChat); + expect(legacyPath.map((message) => message.role), [TavernRole.narrator, TavernRole.user]); + expect(legacyPath.map((message) => message.speakerCharacterId), [null, null]); + h.expectNoGeneration(); + }); + + test('batch inputs are captured once and stale or invalid identities never partially write', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Identity'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.user, message: 'first'), + TavernScriptMessageDraft(role: TavernScriptRole.assistant, message: 'second'), + ]); + final original = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + final fields = { + 'message': 'captured', + 'data': { + 'value': [1], + }, + }; + final firstPatch = TavernScriptMessagePatch(ref: original.messages.first.ref, fields: fields); + scriptMap(fields['data'])['late'] = true; + final patches = [ + firstPatch, + TavernScriptMessagePatch(messageId: -1, fields: {'hidden': true}), + ]; + final writing = h.bridge.setChatMessages(context.id, patches); + patches.clear(); + await writing; + final captured = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + expect(captured.messages.first.message, 'captured'); + expect(captured.messages.first.data, { + 'value': [1], + }); + expect(captured.messages.last.hidden, isTrue); + await h.engine.edit(chat, original.messages.last.ref.nodeId, 'native change'); + await expectLater( + () => h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(messageId: 0, fields: {'message': 'stale'}), + ], options: TavernScriptWriteOptions(expectedPathRevision: captured.pathRevision)), + throwsA(scriptError(TavernScriptErrorCode.revisionConflict)), + ); + final before = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + await expectLater( + () => h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(messageId: 0, fields: {'message': 'partial'}), + TavernScriptMessagePatch(messageId: 2, fields: {'message': 'outside'}), + ]), + throwsA(scriptError(TavernScriptErrorCode.range)), + ); + await expectLater( + () => h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch(messageId: 0, ref: before.messages.last.ref, fields: {'message': 'wrong ref'}), + ]), + throwsA(scriptError(TavernScriptErrorCode.invalidArgument)), + ); + final after = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + expect(after.pathRevision, before.pathRevision); + expect(after.messages.map((m) => m.message), ['captured', 'native change']); + h.expectNoGeneration(); + }); + + test('historical page writes do not select it or change descendant snapshots', () async { + final h = await ScriptFixture.open(); + final branch = await _branches(h, withFirstChild: true); + final beforePath = (await h.engine.path(branch.chat)).map((m) => m.nodeId).toList(); + final beforeY = await _data(h, branch.context.id, branch.y); + final beforeX = await _data(h, branch.context.id, branch.x!); + await h.bridge.setChatMessages(branch.context.id, [ + TavernScriptMessagePatch( + ref: branch.s, + fields: { + 'message': 'S-EDIT', + 'data': {'owner': 'S-edited'}, + }, + ), + ]); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), beforePath); + expect(await _data(h, branch.context.id, branch.s), {'owner': 'S-edited'}); + expect(await _data(h, branch.context.id, branch.x!), beforeX); + expect(await _data(h, branch.context.id, branch.y), beforeY); + final selected = (await h.bridge.getChatMessages( + branch.context.id, + const TavernMessageRange(first: 1, last: 1), + includeSwipes: true, + )).messages.single; + expect(selected.ref, branch.t); + expect(selected.swipes.map((s) => s.ref), [branch.s, branch.t]); + expect(selected.swipes.map((s) => s.text), ['S-EDIT', 'T']); + await h.reopen(); + final fresh = await h.context(branch.chat, characterId: branch.character); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), beforePath); + expect(await _data(h, fresh.id, branch.s), {'owner': 'S-edited'}); + expect(await _data(h, fresh.id, branch.x!), beforeX); + expect(await _data(h, fresh.id, branch.y), beforeY); + h.expectNoGeneration(); + }); + + test('swipe shrink preserves retained remembered child and reparents removed page branches', () async { + final h = await ScriptFixture.open(); + final branch = await _branches(h, withFirstChild: true); + final beforeX = await _data(h, branch.context.id, branch.x!); + final beforeY = await _data(h, branch.context.id, branch.y); + await expectLater( + () => h.bridge.setChatMessages(branch.context.id, [ + TavernScriptMessagePatch( + ref: branch.s, + fields: { + 'swipes': ['S'], + }, + ), + ]), + throwsA(scriptError(TavernScriptErrorCode.invalidArgument)), + ); + final mutation = await h.bridge.setChatMessages(branch.context.id, [ + TavernScriptMessagePatch( + ref: branch.t, + fields: { + 'swipes': ['S'], + 'swipeId': 0, + }, + ), + ]); + expect(mutation.removedRefs, [branch.t]); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [ + branch.p.nodeId, + branch.s.nodeId, + branch.x!.nodeId, + ]); + final tree = await h.engine.tree(branch.chat); + expect(tree.singleWhere((n) => n.nodeId == branch.y.nodeId).parentId, branch.s.nodeId); + expect(tree.singleWhere((n) => n.nodeId == branch.y.nodeId).isCurrentPath, isFalse); + expect(tree.singleWhere((n) => n.nodeId == branch.y.nodeId).branchName, 'Y BRANCH'); + expect(await _data(h, branch.context.id, branch.x!), beforeX); + expect(await _data(h, branch.context.id, branch.y), beforeY); + await expectLater( + () => _data(h, branch.context.id, branch.t), + throwsA(scriptError(TavernScriptErrorCode.notFound)), + ); + await h.reopen(); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [ + branch.p.nodeId, + branch.s.nodeId, + branch.x!.nodeId, + ]); + await h.engine.selectNode(branch.chat, branch.y.nodeId); + expect((await h.engine.readVariables(branch.chat))['branch-local'], 'Y'); + final fresh = await h.context(branch.chat, characterId: branch.character); + expect(await _data(h, fresh.id, branch.y), beforeY); + h.expectNoGeneration(); + }); + + test('swipe shrink keeps a retained null successor and rejects conflicting duplicate fields', () async { + final h = await ScriptFixture.open(); + final branch = await _branches(h, withFirstChild: false); + final before = await h.bridge.getChatMessages(branch.context.id, const TavernMessageRange(), includeSwipes: true); + for (final fields in [ + {'swipes': []}, + { + 'swipes': ['S', 'T'], + 'message': 'different', + 'swipeId': 0, + }, + { + 'swipesData': [ + {'one': 1}, + {'two': 2}, + ], + 'data': {'two': 99}, + }, + { + 'swipesInfo': [ + {'extra': {}}, + { + 'extra': {'x': 1}, + }, + ], + 'extra': {'x': 2}, + }, + ]) { + await expectLater( + () => h.bridge.setChatMessages(branch.context.id, [ + TavernScriptMessagePatch(messageId: 0, fields: {'message': 'partial'}), + TavernScriptMessagePatch(ref: branch.t, fields: fields), + ]), + throwsA(scriptError(TavernScriptErrorCode.invalidArgument)), + ); + expect( + (await h.bridge.getChatMessages(branch.context.id, const TavernMessageRange())).pathRevision, + before.pathRevision, + ); + } + await h.bridge.setChatMessages(branch.context.id, [ + TavernScriptMessagePatch( + ref: branch.t, + fields: { + 'swipes': ['S'], + 'swipeId': 0, + 'message': 'S', + 'swipesData': [ + {'retained': true}, + ], + 'data': {'retained': true}, + 'swipesInfo': [ + { + 'extra': {'same': true}, + }, + ], + 'extra': {'same': true}, + }, + ), + ]); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [branch.p.nodeId, branch.s.nodeId]); + expect( + (await h.engine.tree(branch.chat)).singleWhere((n) => n.nodeId == branch.y.nodeId).parentId, + branch.s.nodeId, + ); + await h.reopen(); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [branch.p.nodeId, branch.s.nodeId]); + h.expectNoGeneration(); + }); + + test('deleting a visible floor deletes every swipe and preserves the selected continuation', () async { + final h = await ScriptFixture.open(); + final branch = await _branches(h, withFirstChild: true); + final xData = await _data(h, branch.context.id, branch.x!); + final yData = await _data(h, branch.context.id, branch.y); + final deleted = await h.bridge.deleteChatMessages(branch.context.id, [1]); + expect(deleted.removedRefs.toSet(), {branch.s, branch.t}); + expect(deleted.indexChanges[1], branch.y); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [branch.p.nodeId, branch.y.nodeId]); + final tree = await h.engine.tree(branch.chat); + expect(tree.map((n) => n.nodeId).toSet(), {branch.p.nodeId, branch.x!.nodeId, branch.y.nodeId}); + expect(tree.where((n) => n.nodeId != branch.p.nodeId).map((n) => n.parentId), everyElement(branch.p.nodeId)); + expect((await h.engine.readVariables(branch.chat))['branch-local'], 'Y'); + expect(await _data(h, branch.context.id, branch.x!), xData); + expect(await _data(h, branch.context.id, branch.y), yData); + for (final gone in [branch.s, branch.t]) { + await expectLater(() => _data(h, branch.context.id, gone), throwsA(scriptError(TavernScriptErrorCode.notFound))); + } + expect((await h.engine.searchChat(branch.chat, 'Y')).map((m) => m.nodeId), [branch.y.nodeId]); + await h.reopen(); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [branch.p.nodeId, branch.y.nodeId]); + await h.engine.selectNode(branch.chat, branch.x!.nodeId); + expect((await h.engine.readVariables(branch.chat))['branch-local'], 'X'); + h.expectNoGeneration(); + }); + + test('insert and half-open rotate move complete families without changing their states or IDs', () async { + final h = await ScriptFixture.open(); + final branch = await _branches(h, withFirstChild: true); + final beforeIds = (await h.engine.tree(branch.chat)).map((n) => n.nodeId).toSet(); + final states = {}; + for (final ref in [branch.p, branch.s, branch.t, branch.x!, branch.y]) { + states[ref.nodeId] = await _data(h, branch.context.id, ref); + } + final inserted = await h.bridge.createChatMessages(branch.context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.system, message: 'INSERT', data: {'insert': 1}), + ], insertBefore: 1); + final insertion = inserted.upsertMessages.singleWhere((m) => m.message == 'INSERT').ref; + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [ + branch.p.nodeId, + insertion.nodeId, + branch.t.nodeId, + branch.y.nodeId, + ]); + expect( + (await h.engine.tree( + branch.chat, + )).where((n) => n.nodeId == branch.s.nodeId || n.nodeId == branch.t.nodeId).map((n) => n.parentId), + everyElement(insertion.nodeId), + ); + await h.bridge.rotateChatMessages(branch.context.id, 1, 2, 4); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [ + branch.p.nodeId, + branch.t.nodeId, + branch.y.nodeId, + insertion.nodeId, + ]); + final moved = await h.engine.tree(branch.chat); + expect(moved.map((n) => n.nodeId).toSet(), {...beforeIds, insertion.nodeId}); + expect(moved.singleWhere((n) => n.nodeId == branch.s.nodeId).parentId, branch.p.nodeId); + expect(moved.singleWhere((n) => n.nodeId == branch.x!.nodeId).parentId, branch.s.nodeId); + for (final entry in states.entries) { + expect( + await _data(h, branch.context.id, TavernScriptMessageRef(chatId: branch.chat, nodeId: entry.key)), + entry.value, + ); + } + final copy = (await h.engine.importChats(branch.character, await h.engine.exportChat(branch.chat))).chatIds.single; + expect((await h.engine.path(copy)).map((m) => m.text), ['P', 'T', 'Y', 'INSERT']); + await h.reopen(); + expect((await h.engine.path(branch.chat)).map((m) => m.nodeId), [ + branch.p.nodeId, + branch.t.nodeId, + branch.y.nodeId, + insertion.nodeId, + ]); + h.expectNoGeneration(); + }); + + test('an added swipe inherits its target full state without repeating DSH rules or inventing a model run', () async { + final h = await ScriptFixture.open(); + final character = await h.card('State pages', greeting: 'S', extensions: scriptStateExtension()); + final chat = await h.engine.createChat(character); + final context = await h.context(chat, characterId: character); + final original = (await h.bridge.getChatMessages(context.id, const TavernMessageRange())).messages.single.ref; + await h.engine.setStateValue(chat, '/count', 999); + await h.bridge.replaceVariables(context.id, scriptChat, {'local': 'target'}); + final data = await h.bridge.getVariables(context.id, scriptMessage); + await h.bridge.replaceVariables(context.id, scriptMessage, { + ...data.value, + 'stat_data': {'hp': 7}, + }); + final beforeFile = (await h.engine.exportChat(chat)).first; + final beforeDocument = StChatDocument.decode(beforeFile.fileName, beforeFile.bytes); + final beforeState = scriptMap( + scriptMap(scriptMap(beforeDocument.messages.single['extra'])['dshgo'])['tavernState'], + ); + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + ref: original, + fields: { + 'swipes': ['S', 'T'], + 'swipeId': 1, + }, + ), + ]); + final page = (await h.bridge.getChatMessages( + context.id, + const TavernMessageRange(), + includeSwipes: true, + )).messages.single; + expect(page.swipes.map((swipe) => swipe.ref).first, original); + expect(page.ref, isNot(original)); + expect( + page.swipes.map((swipe) => swipe.data), + everyElement({ + 'dshgo': {'count': 5}, + 'stat_data': {'hp': 7}, + }), + ); + expect((await h.engine.readVariables(chat))['local'], 'target'); + expect((await h.engine.path(chat)).single.usage, isNull); + expect(await h.engine.inspectTurn(chat, nodeId: page.ref.nodeId), isNull); + final afterFile = (await h.engine.exportChat(chat)).first; + final afterDocument = StChatDocument.decode(afterFile.fileName, afterFile.bytes); + final afterState = scriptMap(scriptMap(scriptMap(afterDocument.messages.single['extra'])['dshgo'])['tavernState']); + expect(afterState, beforeState); + final updated = await h.bridge.replaceVariables(context.id, scriptMessage, { + ...page.data!, + 'dshgo': {'count': 999}, + }); + expect(updated.value['dshgo'], {'count': 10}); + expect(updated.stateEdit!.snapshot.effects, isEmpty); + expect((await _data(h, context.id, original))['dshgo'], {'count': 5}); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value['dshgo'], {'count': 10}); + expect((await _data(h, fresh.id, original))['dshgo'], {'count': 5}); + h.expectNoGeneration(); + }); + + test('a real SQLite failure rolls back the complete message batch and its revision', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Atomic'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.user, message: 'A', data: {'value': 'A'}), + TavernScriptMessageDraft(role: TavernScriptRole.assistant, message: 'B', data: {'value': 'B'}), + ]); + final before = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + h.inspect( + (db) => db.execute(""" + CREATE TRIGGER script_test_abort BEFORE UPDATE OF text ON nodes + WHEN NEW.text = 'SQL-ABORT' + BEGIN SELECT RAISE(ABORT, 'script fixture abort'); END + """), + writable: true, + ); + try { + await expectLater( + () => h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + messageId: 0, + fields: { + 'message': 'A-modified', + 'data': {'half': true}, + }, + ), + TavernScriptMessagePatch(messageId: 1, fields: {'message': 'SQL-ABORT'}), + ]), + throwsA(scriptError(TavernScriptErrorCode.storage).having((error) => error.committed, 'committed', isFalse)), + ); + } finally { + h.inspect((db) => db.execute('DROP TRIGGER script_test_abort'), writable: true); + } + final after = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + expect(after.pathRevision, before.pathRevision); + expect(after.messages.map((m) => m.message), ['A', 'B']); + expect(after.messages.map((m) => m.data), [ + {'value': 'A'}, + {'value': 'B'}, + ]); + expect(await h.engine.searchChat(chat, 'modified'), isEmpty); + await h.reopen(); + expect((await h.engine.path(chat)).map((m) => m.text), ['A', 'B']); + h.expectNoGeneration(); + }); +} + +ScriptJson _privateExtra(ScriptFixture h, TavernScriptMessageRef ref) => h.inspect((db) { + final row = db.select('SELECT extra FROM nodes WHERE id = ?', [ref.nodeId]).single; + return scriptMap(scriptMap(jsonDecode(row['extra'] as String))['dshgo']); +}); + +Future _data(ScriptFixture h, String contextId, TavernScriptMessageRef ref) async => + (await h.bridge.getVariables( + contextId, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref), + )).value; + +class _Branches { + _Branches({ + required this.character, + required this.chat, + required this.context, + required this.p, + required this.s, + required this.t, + required this.y, + this.x, + }); + final String character; + final String chat; + final TavernScriptContext context; + final TavernScriptMessageRef p; + final TavernScriptMessageRef s; + final TavernScriptMessageRef t; + final TavernScriptMessageRef? x; + final TavernScriptMessageRef y; +} + +Future<_Branches> _branches(ScriptFixture h, {required bool withFirstChild}) async { + final character = await h.card('Branch'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.user, message: 'P', data: {'owner': 'P'}), + TavernScriptMessageDraft(role: TavernScriptRole.assistant, message: 'S', data: {'owner': 'S'}), + ]); + final initial = await h.bridge.getChatMessages(context.id, const TavernMessageRange()); + final p = initial.messages.first.ref; + final s = initial.messages.last.ref; + await h.bridge.setChatMessages(context.id, [ + TavernScriptMessagePatch( + ref: s, + fields: { + 'swipes': ['S', 'T'], + 'swipesData': [ + {'owner': 'S'}, + {'owner': 'T'}, + ], + 'swipeId': 1, + }, + ), + ]); + final t = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single.ref; + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.user, message: 'Y', data: {'owner': 'Y'}), + ]); + final y = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single.ref; + await h.bridge.replaceVariables(context.id, scriptChat, {'branch-local': 'Y'}); + await h.engine.nameBranch(chat, y.nodeId, 'Y BRANCH'); + await h.bridge.selectSwipe(context.id, s, 0); + TavernScriptMessageRef? x; + if (withFirstChild) { + await h.bridge.createChatMessages(context.id, [ + TavernScriptMessageDraft(role: TavernScriptRole.user, message: 'X', data: {'owner': 'X'}), + ]); + x = (await h.bridge.getChatMessages(context.id, const TavernMessageRange(first: -1))).messages.single.ref; + await h.bridge.replaceVariables(context.id, scriptChat, {'branch-local': 'X'}); + } + await h.bridge.selectSwipe(context.id, t, 1); + expect((await h.engine.path(chat)).map((m) => m.nodeId), [p.nodeId, t.nodeId, y.nodeId]); + return _Branches(character: character, chat: chat, context: context, p: p, s: s, t: t, x: x, y: y); +} diff --git a/packages/dsh_tavern/test/p2_script_test_support.dart b/packages/dsh_tavern/test/p2_script_test_support.dart new file mode 100644 index 0000000000..fe91328142 --- /dev/null +++ b/packages/dsh_tavern/test/p2_script_test_support.dart @@ -0,0 +1,497 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:http/http.dart' as http; +import 'package:sqlite3/sqlite3.dart'; +import 'package:test/test.dart'; + +import 'support/t24_engine_harness.dart' show t24Preset; + +typedef ScriptJson = Map; +typedef ScriptRequest = ({String method, Uri url, ScriptJson body}); + +ScriptJson scriptMap(Object? value) => value! as ScriptJson; + +TypeMatcher scriptError(TavernScriptErrorCode code) => + isA().having((error) => error.code, 'code', code); + +const scriptGlobal = TavernVariableTarget(scope: TavernVariableScope.global); +const scriptChat = TavernVariableTarget(scope: TavernVariableScope.chat); +const scriptMessage = TavernVariableTarget(scope: TavernVariableScope.message); +const scriptCharacter = TavernVariableTarget(scope: TavernVariableScope.character); +const scriptOwner = TavernVariableTarget(scope: TavernVariableScope.script); + +// Real model encoder/decoder, card files and SQLite. Only HTTP responses, +// clock/random and the App host are supplied by the tests. +class ScriptFixture { + ScriptFixture(this.root, this.allowModel); + + static Future open({bool allowModel = false}) async { + final directory = await Directory.systemTemp.createTemp('dsh-p2-script-'); + final h = ScriptFixture(directory, allowModel); + addTearDown(h.dispose); + h.config = TavernModelConfig( + apiKey: () async { + h.keyReads++; + if (!allowModel) { + throw StateError('Storage-only test requested an API key.'); + } + return 'script-fixture-key'; + }, + baseUrl: 'https://p2-script.invalid/v1', + model: 'p2-script-model', + deepseek: false, + thinking: false, + ); + h.model = TavernModelClient( + clientFactory: () { + final peer = _ScriptPeer(h); + h.peers.add(peer); + return peer; + }, + ); + await h.openEngine(); + await h.engine.setPreset(scriptPreset()); + await h.engine.setSettings({ + 'userName': 'User', + 'macroEngine': 'modern', + 'globalRegexScripts': [], + 'worldInfo': { + 'world_info_depth': 10, + 'world_info_budget': 80, + 'world_info_include_names': false, + 'world_info_recursive': false, + }, + }); + return h; + } + + final Directory root; + final bool allowModel; + late TavernModelConfig config; + late TavernModelClient model; + TavernEngine? _engine; + final requests = []; + final pending = []; + final plans = []; + final peers = <_ScriptPeer>[]; + int keyReads = 0; + void Function()? onRandom; + + TavernEngine get engine => _engine!; + TavernScriptBridge get bridge => engine.scripts; + + Future openEngine() async { + _engine = await TavernEngine.openDirectory( + root, + config: config, + modelClient: model, + clock: () => DateTime.utc(2026, 11, 12, 5), + random: () { + onRandom?.call(); + return 0.25; + }, + ); + } + + Future close() async { + final current = _engine; + _engine = null; + await current?.close(); + } + + Future reopen({Future Function()? whileClosed}) async { + await close(); + await whileClosed?.call(); + await openEngine(); + } + + Future card( + String name, { + String greeting = '', + String description = '', + ScriptJson extensions = const {}, + }) async { + final raw = { + 'spec': 'chara_card_v3', + 'spec_version': '3.0', + 'data': { + 'name': name, + 'description': description, + 'personality': '', + 'scenario': '', + 'first_mes': greeting, + 'mes_example': '', + 'creator_notes': '', + 'system_prompt': '', + 'post_history_instructions': '', + 'alternate_greetings': [], + 'tags': [], + 'creator': '', + 'character_version': '', + 'extensions': extensions, + }, + }; + return (await engine.importCard(Uint8List.fromList(utf8.encode(jsonEncode(raw))))).id; + } + + Future context( + String chatId, { + String? characterId, + String? nodeId, + TavernScriptOwner? owner, + String instanceId = 'fixture-instance', + }) => bridge.openContext( + TavernScriptContextSpec( + instanceId: instanceId, + chatId: chatId, + characterId: characterId, + nodeId: nodeId, + script: owner, + ), + ); + + Future emptyChat(String characterId) async { + // Native createChat stores its greeting even when that body is empty. + // Exercise an actually empty selection through the public delete API. + final chat = await engine.createChat(characterId); + for (final greeting in (await engine.path(chat)).reversed) { + await engine.deleteMessage(chat, greeting.nodeId); + } + expect(await engine.path(chat), isEmpty); + return chat; + } + + ScriptResponsePlan reply(String text, {int status = 200}) { + final plan = ScriptResponsePlan.json({ + 'choices': [ + { + 'index': 0, + 'message': {'role': 'assistant', 'content': text}, + 'finish_reason': 'stop', + }, + ], + }, status: status); + pending.add(plan); + plans.add(plan); + return plan; + } + + ScriptResponsePlan stream() { + final plan = ScriptResponsePlan.stream(); + pending.add(plan); + plans.add(plan); + return plan; + } + + T inspect(T Function(Database) action, {bool writable = false}) { + final db = sqlite3.open('${root.path}/tavern.sqlite3', mode: writable ? OpenMode.readWrite : OpenMode.readOnly); + try { + return action(db); + } finally { + db.close(); + } + } + + Future settingsDocument() async => + scriptMap(jsonDecode(await File('${root.path}/settings.json').readAsString())); + + void expectNoGeneration() { + expect(requests, isEmpty); + expect(keyReads, 0); + } + + Future dispose() async { + try { + await close(); + } finally { + for (final plan in plans) { + await plan.dispose(); + } + // Logical generation completion does not imply async* HTTP drainage. + // Release every owned response, then wait for the real client's finally. + await Future.wait([for (final peer in peers) peer.closedSignal.future]); + if (await root.exists()) { + await root.delete(recursive: true); + } + } + expect(await root.exists(), isFalse); + expect(peers.every((peer) => peer.closed), isTrue); + stdout.writeln('P2_SCRIPT_CLEANUP'); + } +} + +ChatPreset scriptPreset({bool streaming = false, String main = ''}) { + final raw = t24Preset().raw; + raw.addAll({ + 'stream_openai': streaming, + 'openai_max_context': 8192, + 'openai_max_tokens': 73, + 'temperature': 0.4, + 'presence_penalty': 0.2, + 'frequency_penalty': 0.1, + 'top_p': 0.9, + 'seed': -1, + 'squash_system_messages': false, + 'new_group_chat_prompt': '', + 'group_nudge_prompt': '', + }); + final prompts = (raw['prompts']! as List).cast(); + prompts.firstWhere((prompt) => prompt['identifier'] == 'main')['content'] = main; + return ChatPreset(raw); +} + +ScriptJson scriptWire(List messages) => { + 'messages': messages, + 'model': 'p2-script-model', + 'temperature': 0.4, + 'max_tokens': 73, + 'stream': false, + 'presence_penalty': 0.2, + 'frequency_penalty': 0.1, + 'top_p': 0.9, +}; + +class ScriptResponsePlan { + ScriptResponsePlan.json(this.body, {this.status = 200}) : source = null; + ScriptResponsePlan.stream() : body = null, status = 200 { + source = StreamController>(sync: true, onListen: () => listening.complete()); + } + final ScriptJson? body; + final int status; + StreamController>? source; + final seen = Completer(); + final listening = Completer(); + Future Function()? beforeResponse; + + void delta(String text) { + source!.add( + utf8.encode( + 'data: ${jsonEncode({ + 'choices': [ + { + 'index': 0, + 'delta': {'content': text}, + 'finish_reason': null, + }, + ], + })}\n\n', + ), + ); + } + + void finish() { + source!.add( + utf8.encode( + 'data: ${jsonEncode({ + 'choices': [ + {'index': 0, 'delta': {}, 'finish_reason': 'stop'}, + ], + })}\n\ndata: [DONE]\n\n', + ), + ); + unawaited(source!.close()); + } + + Future dispose() async { + final controller = source; + if (controller == null || controller.isClosed) { + return; + } + if (listening.isCompleted) { + await controller.close(); + } else { + unawaited(controller.close()); + } + } +} + +class _ScriptPeer extends http.BaseClient { + _ScriptPeer(this.h); + final ScriptFixture h; + final closedSignal = Completer(); + bool closed = false; + + @override + Future send(http.BaseRequest request) async { + final body = scriptMap(jsonDecode(utf8.decode(await request.finalize().toBytes()))); + final captured = (method: request.method, url: request.url, body: body); + h.requests.add(captured); + if (h.pending.isEmpty) { + throw StateError('Unexpected additional script HTTP request.'); + } + final plan = h.pending.removeAt(0); + plan.seen.complete(captured); + await plan.beforeResponse?.call(); + return http.StreamedResponse( + plan.source?.stream ?? Stream.value(utf8.encode(jsonEncode(plan.body))), + plan.status, + headers: {'content-type': plan.source == null ? 'application/json' : 'text/event-stream'}, + ); + } + + @override + void close() { + closed = true; + if (!closedSignal.isCompleted) { + closedSignal.complete(); + } + } +} + +class ScriptEvents { + ScriptEvents(Stream source) { + subscription = source.listen( + (event) { + values.add(event); + changes.add(event); + }, + onError: (Object error, StackTrace stack) { + if (!done.isCompleted) { + done.completeError(error, stack); + } + }, + onDone: () { + if (!done.isCompleted) { + done.complete(); + } + unawaited(changes.close()); + }, + ); + addTearDown(detach); + } + final values = []; + final changes = StreamController.broadcast(sync: true); + final done = Completer(); + late final StreamSubscription subscription; + + Future until(bool Function(T) accept) { + for (final event in values) { + if (accept(event)) { + return Future.value(event); + } + } + return Future.any([ + changes.stream.firstWhere(accept), + done.future.then((_) => throw TestFailure('Stream ended before the expected event.')), + ]); + } + + Future waitRequest(ScriptResponsePlan plan) => Future.any([ + plan.seen.future, + done.future.then((_) => throw TestFailure('Stream ended before the expected request.')), + ]); + + Future detach() async { + await subscription.cancel(); + if (!changes.isClosed) { + await changes.close(); + } + } +} + +class ScriptHost implements TavernScriptHost { + final deliveries = []; + final refreshes = []; + Future Function(TavernScriptDispatch, TavernScriptCancellation)? onDispatch; + Future Function(TavernScriptRefreshRequest)? onRefresh; + + @override + Future dispatch(TavernScriptDispatch request, TavernScriptCancellation cancellation) async { + deliveries.add(request); + return await onDispatch?.call(request, cancellation) ?? TavernScriptDispatchAck(eventId: request.eventId); + } + + @override + Future refresh(TavernScriptRefreshRequest request) async { + refreshes.add(request); + await onRefresh?.call(request); + } + + @override + Future interact(TavernScriptUiRequest request, TavernScriptCancellation cancellation) => + Future.error(StateError('B1 test must not request an App UI operation.')); + + @override + Future invoke( + TavernScriptCallbackRequest request, + TavernScriptCancellation cancellation, + ) => Future.error(StateError('B1 test must not request a JS filter callback.')); + + @override + Future resolveSlash(TavernSlashReference request, TavernScriptCancellation cancellation) => + Future.error(StateError('B1 test must not resolve a Slash program.')); +} + +ScriptJson scriptStateExtension({int initial = 0}) => { + 'dshgo': { + 'v': 1, + 'state': { + 'schema': { + 'type': 'object', + 'additionalProperties': false, + 'required': ['count'], + 'properties': { + 'count': {'type': 'integer', 'minimum': 0, 'maximum': 100, 'default': 0, 'x-dsh-step': 5}, + }, + }, + 'init': {'count': initial}, + 'rules': [ + { + 'id': 'once-on-positive', + 'when': { + '>': [ + {'var': 'count'}, + 0, + ], + }, + 'once': true, + 'do': [ + {'type': 'notice', 'text': 'script-rule-once'}, + ], + }, + ], + }, + }, +}; + +// Shape from the public "getScriptTrees" documentation, not helper source. +ScriptJson scriptContainer(String initial) => { + 'variables': {'card-variable': initial}, + 'unknown_container': {'keep': true}, + 'scripts': [ + { + 'type': 'folder', + 'id': 'folder', + 'name': 'Folder', + 'enabled': true, + 'icon': 'book', + 'color': '#123456', + 'foreign_folder': [1, null], + 'scripts': [ + { + 'type': 'script', + 'id': 'same-script', + 'name': 'Saved script', + 'enabled': true, + 'content': '/* opaque; never executed by this test */', + 'info': 'kept', + 'button': { + 'enabled': true, + 'buttons': [ + {'name': 'Go', 'visible': true}, + ], + }, + 'export_with': {'data': true, 'button': false}, + 'data': {'start': initial}, + 'foreign_script': { + 'keep': [1, false], + }, + }, + ], + }, + ], +}; diff --git a/packages/dsh_tavern/test/p2_script_variables_test.dart b/packages/dsh_tavern/test/p2_script_variables_test.dart new file mode 100644 index 0000000000..73c3bc3a03 --- /dev/null +++ b/packages/dsh_tavern/test/p2_script_variables_test.dart @@ -0,0 +1,618 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:test/test.dart'; + +import 'p2_script_test_support.dart'; + +void main() { + test('five persistent scopes keep script owners and iframe instances distinct', () async { + final h = await ScriptFixture.open(); + final first = await h.card('Twin', extensions: {'tavern_helper': scriptContainer('A')}); + final second = await h.card('Twin', extensions: {'tavern_helper': scriptContainer('B')}); + final presetRaw = scriptPreset().raw + ..['name'] = 'Script owner preset' + ..['extensions'] = { + 'tavern_helper': scriptContainer('P'), + 'foreign_preset': [1, null], + }; + final preset = (await h.engine.importPreset(jsonEncode(presetRaw))).preset.id; + final chat = await h.emptyChat(first); + expect(await h.engine.path(chat), isEmpty); + final ownerA = TavernScriptOwner(kind: TavernScriptOwnerKind.character, assetId: first, scriptId: 'same-script'); + final ownerB = TavernScriptOwner(kind: TavernScriptOwnerKind.character, assetId: second, scriptId: 'same-script'); + final ownerP = TavernScriptOwner(kind: TavernScriptOwnerKind.preset, assetId: preset, scriptId: 'same-script'); + const ownerG = TavernScriptOwner(kind: TavernScriptOwnerKind.global, scriptId: 'same-script'); + final a = await h.context(chat, characterId: first, owner: ownerA); + final b = await h.context(chat, characterId: second, owner: ownerB, instanceId: 'second-frame'); + final p = await h.context(chat, characterId: first, owner: ownerP, instanceId: 'preset-frame'); + final g = await h.context(chat, characterId: first, owner: ownerG, instanceId: 'global-frame'); + final a2 = await h.context(chat, characterId: first, owner: ownerA, instanceId: 'another-instance'); + expect(a.id, isNot(a2.id)); + expect((await h.bridge.getVariables(a.id, scriptOwner)).value, {'start': 'A'}); + expect((await h.bridge.getVariables(b.id, scriptOwner)).value, {'start': 'B'}); + expect((await h.bridge.getVariables(p.id, scriptOwner)).value, {'start': 'P'}); + expect((await h.bridge.getVariables(a.id, scriptCharacter)).value, {'card-variable': 'A'}); + final emptyChat = await h.bridge.replaceVariables(a.id, scriptChat, {'local': 7}); + expect(emptyChat.value, {'local': 7}); + expect((await h.bridge.getVariables(a.id, scriptChat)).value, {'local': 7}); + final made = await h.bridge.createChatMessages(a.id, [ + TavernScriptMessageDraft( + role: TavernScriptRole.user, + message: 'stored', + data: { + 'stat_data': {'hp': 3}, + }, + ), + ]); + final ref = made.upsertMessages.single.ref; + final targetMessage = TavernVariableTarget(scope: TavernVariableScope.message, messageRef: ref); + final writes = [ + ( + context: a.id, + target: scriptGlobal, + value: { + 'global': [1, null], + }, + ), + (context: a.id, target: scriptCharacter, value: {'character': 'A2'}), + ( + context: a.id, + target: targetMessage, + value: { + 'stat_data': {'hp': 9}, + }, + ), + (context: a.id, target: scriptOwner, value: {'owner': 'A'}), + (context: b.id, target: scriptOwner, value: {'owner': 'B'}), + (context: p.id, target: scriptOwner, value: {'owner': 'P'}), + (context: g.id, target: scriptOwner, value: {'owner': 'G'}), + ]; + for (final write in writes) { + final actual = await h.bridge.replaceVariables(write.context, write.target, write.value); + expect(actual.value, write.value); + expect((await h.bridge.getVariables(write.context, write.target)).value, write.value); + } + expect((await h.bridge.getVariables(a2.id, scriptOwner)).value, {'owner': 'A'}); + expect((await h.bridge.getVariables(b.id, scriptCharacter)).value, {'card-variable': 'B'}); + expect((await h.bridge.getVariables(a.id, scriptChat)).value, {'local': 7}); + expect(h.engine.globalVariables, { + 'global': [1, null], + }); + final fields = (await h.engine.characterFields(first))!; + final container = scriptMap(scriptMap(fields['extensions'])['tavern_helper']); + expect(container['unknown_container'], {'keep': true}); + final folder = scriptMap((container['scripts']! as List).single); + final savedScript = scriptMap((folder['scripts']! as List).single); + expect(folder['foreign_folder'], [1, null]); + expect(savedScript['content'], '/* opaque; never executed by this test */'); + expect(savedScript['foreign_script'], { + 'keep': [1, false], + }); + expect(savedScript['export_with'], {'data': true, 'button': false}); + expect(savedScript['button'], { + 'enabled': true, + 'buttons': [ + {'name': 'Go', 'visible': true}, + ], + }); + final versionBeforeClose = (await h.bridge.getVariables(a.id, scriptOwner)).version; + await h.reopen(); + final fresh = await h.context(chat, characterId: first, owner: ownerA); + expect(fresh.engineEpoch, isNot(a.engineEpoch)); + expect((await h.bridge.getVariables(fresh.id, scriptOwner)).value, {'owner': 'A'}); + expect((await h.bridge.getVariables(fresh.id, scriptOwner)).version, isNot(versionBeforeClose)); + expect((await h.bridge.getVariables(fresh.id, targetMessage)).value, { + 'stat_data': {'hp': 9}, + }); + expect((await h.bridge.getVariables(fresh.id, scriptChat)).value, {'local': 7}); + for (final owner in [ownerB, ownerP, ownerG]) { + final other = await h.context( + chat, + characterId: first, + owner: owner, + instanceId: 'reopened-${owner.kind.name}${owner.assetId ?? ''}', + ); + final expected = owner == ownerB + ? 'B' + : owner == ownerP + ? 'P' + : 'G'; + expect((await h.bridge.getVariables(other.id, scriptOwner)).value, {'owner': expected}); + } + h.expectNoGeneration(); + stdout.writeln('P2_SCRIPT_VARIABLES_READY'); + }); + + test('path mutations preserve null presence and reject a whole invalid batch', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Variables'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.replaceVariables(context.id, scriptChat, { + 'null': null, + 'false': false, + 'zero': 0, + 'empty': '', + 'array': [10, 20], + 'nested': { + 'a/b': {'~x': 1}, + }, + }); + final result = await h.bridge.mutateVariables(context.id, scriptChat, [ + for (final key in ['null', 'false', 'zero', 'empty']) + TavernVariableChange( + kind: TavernVariableChangeKind.insertIfAbsent, + pathTokens: [key], + value: 'must not replace', + ), + TavernVariableChange(kind: TavernVariableChangeKind.insertIfAbsent, pathTokens: ['new'], value: 7), + TavernVariableChange(kind: TavernVariableChangeKind.set, pathTokens: ['nested', 'a/b', '~x'], value: 3), + TavernVariableChange(kind: TavernVariableChangeKind.remove, pathTokens: ['array', 0]), + TavernVariableChange(kind: TavernVariableChangeKind.remove, pathTokens: ['absent']), + TavernVariableChange(kind: TavernVariableChangeKind.remove, pathTokens: ['null']), + ]); + expect(result.snapshot.value, { + 'false': false, + 'zero': 0, + 'empty': '', + 'array': [20], + 'nested': { + 'a/b': {'~x': 3}, + }, + 'new': 7, + }); + expect(result.results.take(4).map((r) => [r.existed, r.changed]), [ + [true, false], + [true, false], + [true, false], + [true, false], + ]); + expect(result.results[4].existed, isFalse); + expect(result.results[7].changed, isFalse); + expect(result.results[8].existed, isTrue); + final before = await h.bridge.getVariables(context.id, scriptChat); + await expectLater( + () => h.bridge.mutateVariables(context.id, scriptChat, [ + TavernVariableChange(kind: TavernVariableChangeKind.set, pathTokens: ['half'], value: 'must roll back'), + TavernVariableChange(kind: TavernVariableChangeKind.set, pathTokens: ['array', 1.5], value: 4), + ]), + throwsA(scriptError(TavernScriptErrorCode.invalidArgument)), + ); + final after = await h.bridge.getVariables(context.id, scriptChat); + expect(after.value, before.value); + expect(after.version, before.version); + await h.bridge.replaceVariables(context.id, scriptChat, {}); + expect((await h.bridge.getVariables(context.id, scriptChat)).value, isEmpty); + await h.reopen(); + final again = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(again.id, scriptChat)).value, isEmpty); + h.expectNoGeneration(); + }); + + test('CAS sees native writes while updater runs once outside storage queues', () async { + final h = await ScriptFixture.open(); + final character = await h.card('CAS'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + await h.bridge.replaceVariables(context.id, scriptGlobal, {'generation': 0}); + final old = await h.bridge.getVariables(context.id, scriptGlobal); + await h.engine.setGlobalVariables({'generation': 1}); + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptGlobal, {'generation': 99}, expectedVersion: old.version), + throwsA(scriptError(TavernScriptErrorCode.revisionConflict)), + ); + expect(h.engine.globalVariables, {'generation': 1}); + final beforeAba = await h.bridge.getVariables(context.id, scriptGlobal); + await h.engine.setGlobalVariables({'generation': 8}); + await h.engine.setGlobalVariables({'generation': 1}); + final afterAba = await h.bridge.getVariables(context.id, scriptGlobal); + expect(afterAba.value, beforeAba.value); + expect( + afterAba.version, + isNot(beforeAba.version), + reason: 'A committed intervening change cannot recreate an earlier revision.', + ); + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptGlobal, {'generation': 99}, expectedVersion: beforeAba.version), + throwsA(scriptError(TavernScriptErrorCode.revisionConflict)), + ); + final entered = Completer(); + final release = Completer(); + addTearDown(() { + if (!release.isCompleted) { + release.complete(); + } + }); + var calls = 0; + final updating = h.bridge.updateVariablesWith(context.id, scriptGlobal, (value) async { + calls++; + expect(value, {'generation': 1}); + entered.complete(); + await release.future; + return {'generation': 100}; + }); + final rejected = expectLater(updating, throwsA(scriptError(TavernScriptErrorCode.revisionConflict))); + await entered.future; + await h.engine.setGlobalVariables({'generation': 2}); + release.complete(); + await rejected; + expect(calls, 1); + expect(h.engine.globalVariables, {'generation': 2}); + final currentGlobal = await h.bridge.getVariables(context.id, scriptGlobal); + await h.bridge.replaceVariables(context.id, scriptChat, {'unrelated': 4}); + await h.bridge.replaceVariables(context.id, scriptGlobal, { + 'generation': 3, + }, expectedVersion: currentGlobal.version); + expect(h.engine.globalVariables, {'generation': 3}); + // A native macro modifies the same persisted chat target and must invalidate + // a token obtained by the bridge before that normal engine call. + final oldChat = await h.bridge.getVariables(context.id, scriptChat); + await h.engine.sendOnly(chat, '{{setvar::native::NATIVE}}body'); + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptChat, {'stale': true}, expectedVersion: oldChat.version), + throwsA(scriptError(TavernScriptErrorCode.revisionConflict)), + ); + expect((await h.bridge.getVariables(context.id, scriptChat)).value['native'], 'NATIVE'); + h.expectNoGeneration(); + }); + + test('strict JSON is captured at call time and invalid values never partially save', () async { + final h = await ScriptFixture.open(); + final character = await h.card('JSON'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final input = { + 'nested': [ + 1, + {'x': 2}, + ], + }; + final writing = h.bridge.replaceVariables(context.id, scriptGlobal, input); + (input['nested']! as List).add('late'); + input['late'] = true; + final result = await writing; + expect(result.value, { + 'nested': [ + 1, + {'x': 2}, + ], + }); + (result.value['nested']! as List).clear(); + expect((await h.bridge.getVariables(context.id, scriptGlobal)).value, { + 'nested': [ + 1, + {'x': 2}, + ], + }); + final before = await h.bridge.getVariables(context.id, scriptGlobal); + final cycle = {}; + cycle['self'] = cycle; + final object = _JsonTrap(); + for (final bad in [ + double.nan, + double.infinity, + {1: 'non-string-key'}, + cycle, + object, + () => 1, + ]) { + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptGlobal, {'valid': 'must not save', 'bad': bad}), + throwsA(scriptError(TavernScriptErrorCode.invalidJson)), + ); + final actual = await h.bridge.getVariables(context.id, scriptGlobal); + expect(actual.value, before.value); + expect(actual.version, before.version); + } + expect(object.calls, 0, reason: 'Arbitrary toJson methods are outside standard JSON input.'); + h.expectNoGeneration(); + }); + + test('real file failure rolls back while native settings and macro writes preserve committed globals', () async { + final h = await ScriptFixture.open(); + final character = await h.card('File'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final initial = await h.bridge.replaceVariables(context.id, scriptGlobal, {'saved': 'before'}); + final originalFile = await File('${h.root.path}/settings.json').readAsBytes(); + final blocker = Directory('${h.root.path}/settings.json.writing'); + await blocker.create(); + try { + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptGlobal, {'saved': 'failed'}), + throwsA(scriptError(TavernScriptErrorCode.storage).having((error) => error.committed, 'committed', isFalse)), + ); + expect(await File('${h.root.path}/settings.json').readAsBytes(), originalFile); + expect(h.engine.globalVariables, {'saved': 'before'}); + final actual = await h.bridge.getVariables(context.id, scriptGlobal); + expect(actual.value, initial.value); + expect(actual.version, initial.version); + } finally { + await blocker.delete(recursive: true); + } + // Both real writers are admitted without waiting for the preceding rename. + // A native pre-encoded document must not restore the old global object. + final bridgeWrite = h.bridge.replaceVariables(context.id, scriptGlobal, {'saved': 'after'}); + final nativeWrite = h.engine.setSettings({'userName': 'Native settings'}); + await bridgeWrite; + await nativeWrite; + final document = await h.settingsDocument(); + expect(document['globalVariables'], {'saved': 'after'}); + expect(scriptMap(document['settings'])['userName'], 'Native settings'); + expect(h.engine.globalVariables, {'saved': 'after'}); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(fresh.id, scriptGlobal)).value, {'saved': 'after'}); + + // The real macro calls the injected RNG synchronously. Admit the bridge + // replacement there, before the native macro mutates globals and queues + // its own preparation save. This fixes queue order without sleeps or fake + // filesystem work, and executes the production macro interpreter. + final otherChat = await h.emptyChat(character); + final macroBridge = Completer(); + var randomCalls = 0; + h.onRandom = () { + randomCalls++; + h.onRandom = null; + macroBridge.complete(h.bridge.replaceVariables(fresh.id, scriptGlobal, {'bridge': 'persisted'})); + }; + addTearDown(() { + h.onRandom = null; + }); + final narrating = h.engine.narrate(otherChat, '{{random::A::B}}{{setglobalvar::native::kept}}'); + await Future.wait([narrating, macroBridge.future]); + expect(randomCalls, 1); + expect((await narrating).text, 'A'); + const merged = {'bridge': 'persisted', 'native': 'kept'}; + expect(h.engine.globalVariables, merged); + expect((await h.settingsDocument())['globalVariables'], merged); + final mergedSnapshot = await h.bridge.getVariables(fresh.id, scriptGlobal); + expect(mergedSnapshot.value, merged); + expect(mergedSnapshot.version, isNot((await macroBridge.future).version)); + h.expectNoGeneration(); + await h.reopen(); + final afterMacro = await h.context(chat, characterId: character); + expect(h.engine.globalVariables, merged); + expect((await h.bridge.getVariables(afterMacro.id, scriptGlobal)).value, merged); + expect((await h.engine.path(otherChat)).single.text, 'A'); + + // A partial bridge mutation admitted after a native write keeps native + // keys it does not mention. Its queue callback merges that pending prefix + // before applying the path plan once. + final nativeFirstChat = await h.emptyChat(character); + final pathBridge = Completer(); + randomCalls = 0; + h.onRandom = () { + randomCalls++; + h.onRandom = null; + pathBridge.complete( + h.bridge.mutateVariables(afterMacro.id, scriptGlobal, [ + TavernVariableChange( + kind: TavernVariableChangeKind.insertIfAbsent, + pathTokens: ['pathBridge'], + value: 'inserted', + ), + ]), + ); + }; + final nativeFirst = h.engine.narrate(nativeFirstChat, '{{setglobalvar::nativeBefore::kept}}{{random::A::B}}'); + await Future.wait([nativeFirst, pathBridge.future]); + expect(randomCalls, 1); + expect((await nativeFirst).text, 'A'); + const pathMerged = {'bridge': 'persisted', 'native': 'kept', 'nativeBefore': 'kept', 'pathBridge': 'inserted'}; + expect((await pathBridge.future).snapshot.value, pathMerged); + expect(h.engine.globalVariables, pathMerged); + expect((await h.settingsDocument())['globalVariables'], pathMerged); + + // These two native operations are intents even though, at execution time, + // the live value is already 'old' and removeMe is absent. Both must survive + // the earlier queued replacement without re-running the real macro/RNG. + await h.bridge.replaceVariables(afterMacro.id, scriptGlobal, {'same': 'old'}); + final intentChat = await h.emptyChat(character); + final intentBridge = Completer(); + randomCalls = 0; + h.onRandom = () { + randomCalls++; + h.onRandom = null; + intentBridge.complete(h.bridge.replaceVariables(afterMacro.id, scriptGlobal, {'same': 'new', 'removeMe': 'new'})); + }; + final intents = h.engine.narrate( + intentChat, + '{{random::A::B}}{{setglobalvar::same::old}}{{deleteglobalvar::removeMe}}', + ); + await Future.wait([intents, intentBridge.future]); + expect(randomCalls, 1); + expect((await intents).text, 'A'); + expect((await intentBridge.future).value, {'same': 'new', 'removeMe': 'new'}); + expect(h.engine.globalVariables, {'same': 'old'}); + expect((await h.settingsDocument())['globalVariables'], {'same': 'old'}); + expect((await h.bridge.getVariables(afterMacro.id, scriptGlobal)).value, {'same': 'old'}); + h.expectNoGeneration(); + await h.reopen(); + final afterIntents = await h.context(chat, characterId: character); + expect(h.engine.globalVariables, {'same': 'old'}); + expect((await h.bridge.getVariables(afterIntents.id, scriptGlobal)).value, {'same': 'old'}); + expect((await h.engine.path(nativeFirstChat)).single.text, 'A'); + expect((await h.engine.path(intentChat)).single.text, 'A'); + h.expectNoGeneration(); + }); + + test('historical missing null and array message data stay distinguishable until explicit repair', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Imported'); + final document = StChatDocument( + fileName: 'shapes.jsonl', + header: {'user_name': 'User', 'character_name': 'Imported', 'chat_metadata': {}}, + messages: [ + { + 'name': 'User', + 'is_user': true, + 'is_system': false, + 'mes': 'missing', + 'send_date': '2026-11-12T05:00:00Z', + 'extra': {}, + }, + { + 'name': 'Imported', + 'is_user': false, + 'is_system': false, + 'mes': 'valid', + 'send_date': '2026-11-12T05:00:01Z', + 'extra': {}, + 'swipe_id': 2, + 'swipes': ['null page', 'array page', 'valid'], + 'variables': [ + null, + ['raw-array'], + {'good': 1}, + ], + }, + ], + ); + final chat = (await h.engine.importChats(character, [ + TavernChatFile(fileName: document.fileName, bytes: document.encode()), + ])).chatIds.single; + final context = await h.context(chat, characterId: character); + final page = await h.bridge.getChatMessages(context.id, const TavernMessageRange(), includeSwipes: true); + expect(page.messages.first.hasData, isFalse); + expect(page.messages.first.data, isEmpty); + final swipes = page.messages.last.swipes; + expect(swipes, hasLength(3)); + expect(swipes[0].hasData, isTrue); + expect(swipes[0].rawData, isNull); + expect(swipes[0].data, isNull); + expect(swipes[0].dataIssue, isNotNull); + expect(swipes[1].rawData, ['raw-array']); + expect(swipes[1].data, isNull); + expect(swipes[1].dataIssue, isNotNull); + expect(swipes[2].data, {'good': 1}); + for (final swipe in swipes.take(2)) { + await expectLater( + () => h.bridge.getVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: swipe.ref), + ), + throwsA(scriptError(TavernScriptErrorCode.shapeMismatch)), + ); + } + final repaired = await h.bridge.replaceVariables( + context.id, + TavernVariableTarget(scope: TavernVariableScope.message, messageRef: swipes[0].ref), + {'fixed': 0}, + ); + expect(repaired.value, {'fixed': 0}); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, {'good': 1}); + final exported = (await h.engine.exportChat(chat)).single; + final out = StChatDocument.decode(exported.fileName, exported.bytes); + expect(out.messages.last['variables'], [ + {'fixed': 0}, + ['raw-array'], + {'good': 1}, + ]); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + final again = await h.bridge.getChatMessages(fresh.id, const TavernMessageRange(), includeSwipes: true); + expect(again.messages.last.ref, swipes[2].ref); + expect(again.messages.last.swipes[0].data, {'fixed': 0}); + expect(again.messages.last.swipes[1].rawData, ['raw-array']); + h.expectNoGeneration(); + }); + + test('message data shares DSH validation while ordinary sibling writes preserve runtime', () async { + final h = await ScriptFixture.open(); + final character = await h.card('State', greeting: 'state greeting', extensions: scriptStateExtension()); + final chat = await h.engine.createChat(character); + final context = await h.context(chat, characterId: character); + final initial = await h.bridge.getVariables(context.id, scriptMessage); + expect(initial.value['dshgo'], {'count': 0}); + final withMvu = { + ...initial.value, + 'stat_data': {'hp': 3}, + 'vendor': [false, null], + }; + await h.bridge.replaceVariables(context.id, scriptMessage, withMvu); + final beforeRejected = await h.bridge.getVariables(context.id, scriptMessage); + await expectLater( + () => h.bridge.replaceVariables(context.id, scriptMessage, { + 'stat_data': {'hp': 99}, + }), + throwsA( + scriptError(TavernScriptErrorCode.stateValidation).having((error) => error.committed, 'committed', isFalse), + ), + ); + expect((await h.bridge.getVariables(context.id, scriptMessage)).value, beforeRejected.value); + final changed = await h.bridge.replaceVariables(context.id, scriptMessage, { + ...withMvu, + 'dshgo': {'count': 999}, + 'stat_data': {'hp': 4}, + }); + expect(changed.value['dshgo'], {'count': 5}); + expect(changed.value['stat_data'], {'hp': 4}); + expect(changed.stateEdit, isNotNull); + expect(changed.stateEdit!.applied, isTrue); + expect(changed.stateEdit!.snapshot.effects.single['text'], 'script-rule-once'); + final beforeOrdinary = await _savedState(h, chat); + await h.bridge.replaceVariables(context.id, scriptMessage, { + ...changed.value, + 'stat_data': {'hp': 8}, + }); + final afterOrdinary = await _savedState(h, chat); + for (final key in beforeOrdinary.keys.where((key) => key != 'messageVariables')) { + expect(afterOrdinary[key], beforeOrdinary[key], reason: key); + } + expect(scriptMap(afterOrdinary['messageVariables'])['stat_data'], {'hp': 8}); + final next = await h.bridge.replaceVariables(context.id, scriptMessage, { + ...changed.value, + 'dshgo': {'count': 999}, + 'stat_data': {'hp': 9}, + }); + expect(next.value['dshgo'], {'count': 10}, reason: 'Each user edit has a fresh step invocation.'); + expect(next.stateEdit!.snapshot.effects, isEmpty, reason: 'The once rule ledger survives ordinary data writes.'); + await h.reopen(); + final fresh = await h.context(chat, characterId: character); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value['dshgo'], {'count': 10}); + expect((await h.bridge.getVariables(fresh.id, scriptMessage)).value['stat_data'], {'hp': 9}); + h.expectNoGeneration(); + }); + + test('a user updater may close the engine without being retained as storage IO', () async { + final h = await ScriptFixture.open(); + final character = await h.card('Close updater'); + final chat = await h.emptyChat(character); + final context = await h.context(chat, characterId: character); + final bridge = h.bridge; + await bridge.replaceVariables(context.id, scriptGlobal, {'kept': true}); + var calls = 0; + final actuallyClosed = Completer(); + final updating = bridge.updateVariablesWith(context.id, scriptGlobal, (value) async { + calls++; + await h.close(); + actuallyClosed.complete(); + return {'late': 'must not save after close'}; + }); + await expectLater(updating, throwsA(isA())); + expect(calls, 1); + await actuallyClosed.future; + await h.openEngine(); + expect(h.engine.globalVariables, {'kept': true}); + h.expectNoGeneration(); + }); +} + +class _JsonTrap { + int calls = 0; + Object toJson() { + calls++; + return {'converted': true}; + } +} + +Future _savedState(ScriptFixture h, String chat) async { + final files = await h.engine.exportChat(chat); + final document = StChatDocument.decode(files.first.fileName, files.first.bytes); + final saved = scriptMap(scriptMap(scriptMap(document.messages.last['extra'])['dshgo'])['tavernState']); + return scriptMap(jsonDecode(jsonEncode(saved))); +} From b82e7426b7a56ee30f9447fc12c9c71ea2339b89 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 22:50:15 +0800 Subject: [PATCH 09/51] =?UTF-8?q?feat(tavern):=20App=20=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E5=8D=A1=E8=84=9A=E6=9C=AC=E6=A1=A5=EF=BC=88DeferredTavern=20?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E3=80=81=E9=A2=84=E8=A7=88=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E8=AF=B4=E4=B8=8D=E6=A8=A1=E6=8B=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeferredTavern.scripts 是同一个延迟代理:引擎打开后直接调真桥(调用时就被引擎 拷贝),打开前列表和原始 JSON 在调用时拷贝、环形值当场拒绝,打开前设的 host 打开时交过去,打开前不声称任何能力。PreviewTavern.scripts 标 simulated、 不列方法,所有调用回 unsupportedCapability。TavernTurnFailed 不再是 const。 测试用真引擎走了一遍:写全局变量、读回、重开还在。 --- app/lib/tavern/deferred_tavern.dart | 186 ++++++++++++++++++ app/lib/tavern/tavern_preview.dart | 120 +++++++++++ app/test/tavern/deferred_tavern_test.dart | 85 ++++++++ .../tavern/tavern_chat_recovery_test.dart | 2 +- 4 files changed, 392 insertions(+), 1 deletion(-) diff --git a/app/lib/tavern/deferred_tavern.dart b/app/lib/tavern/deferred_tavern.dart index 66e9ef6533..75fef2a935 100644 --- a/app/lib/tavern/deferred_tavern.dart +++ b/app/lib/tavern/deferred_tavern.dart @@ -487,6 +487,11 @@ class DeferredTavern implements Tavern { Future> readDirectorEvents(String chatId, {int limit = 50, String? beforeEventId}) async => (await _ready).readDirectorEvents(chatId, limit: limit, beforeEventId: beforeEventId); + /// One bridge for as long as this tavern lives (P2 B1): calls go straight + /// to the engine's once it is open, and wait for it before. + @override + late final TavernScriptBridge scripts = _DeferredScripts(this); + /// Nothing to close when it never opened. @override Future close() async { @@ -499,3 +504,184 @@ class DeferredTavern implements Tavern { await tavern.close(); } } + +/// [DeferredTavern.scripts]. Once the engine is open each call goes straight +/// to its bridge, so the bridge copies what it is given at the call, as its +/// contract asks; before that, lists and the one raw JSON object are copied +/// here and the call waits for the engine. The latest host reaches the +/// engine when it opens. Until then nothing is claimed: no methods, no +/// scopes. +class _DeferredScripts implements TavernScriptBridge { + _DeferredScripts(this._tavern) { + unawaited( + _tavern._ready.then((t) { + if (_pending) t.scripts.attachHost(_host); + _pending = false; + }, onError: (Object _) {}), + ); + } + + final DeferredTavern _tavern; + TavernScriptHost? _host; + + /// A host was set while the engine was still opening: it gets it on open. + bool _pending = false; + + Future _call(Future Function(TavernScriptBridge b) call) { + final open = _tavern._open; + return open != null ? call(open.scripts) : _tavern._ready.then((t) => call(t.scripts)); + } + + @override + TavernScriptCapabilities get capabilities => + _tavern._open?.scripts.capabilities ?? + TavernScriptCapabilities(adapterVersion: '', methods: const {}, scopes: const {}, hostAttached: _host != null); + + @override + void attachHost(TavernScriptHost? host) { + _host = host; + final open = _tavern._open; + _pending = open == null; + open?.scripts.attachHost(host); + } + + @override + Future openContext(TavernScriptContextSpec spec) => _call((b) => b.openContext(spec)); + + @override + Future snapshot(String contextId) => _call((b) => b.snapshot(contextId)); + + @override + Future closeContext(String contextId) => _call((b) => b.closeContext(contextId)); + + @override + Future activateChat(String contextId) => _call((b) => b.activateChat(contextId)); + + @override + Stream watchEvents({String? chatId}) => + _tavern._stream((t) => t.scripts.watchEvents(chatId: chatId)); + + @override + Future getChatMessages( + String contextId, + TavernMessageRange range, { + TavernScriptRole? role, + TavernHiddenFilter hidden = TavernHiddenFilter.all, + bool includeSwipes = false, + }) => _call((b) => b.getChatMessages(contextId, range, role: role, hidden: hidden, includeSwipes: includeSwipes)); + + @override + Future setChatMessages( + String contextId, + List patches, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) { + final held = List.of(patches); + return _call((b) => b.setChatMessages(contextId, held, options: options)); + } + + @override + Future createChatMessages( + String contextId, + List messages, { + int? insertBefore, + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) { + final held = List.of(messages); + return _call((b) => b.createChatMessages(contextId, held, insertBefore: insertBefore, options: options)); + } + + @override + Future deleteChatMessages( + String contextId, + List messageIds, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) { + final held = List.of(messageIds); + return _call((b) => b.deleteChatMessages(contextId, held, options: options)); + } + + @override + Future rotateChatMessages( + String contextId, + int begin, + int middle, + int end, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _call((b) => b.rotateChatMessages(contextId, begin, middle, end, options: options)); + + @override + Future selectSwipe( + String contextId, + TavernScriptMessageRef message, + int swipeId, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _call((b) => b.selectSwipe(contextId, message, swipeId, options: options)); + + @override + Future getVariables(String contextId, TavernVariableTarget target) => + _call((b) => b.getVariables(contextId, target)); + + @override + Future replaceVariables( + String contextId, + TavernVariableTarget target, + TavernJsonObject value, { + String? expectedVersion, + }) { + if (_tavern._open case final open?) { + return open.scripts.replaceVariables(contextId, target, value, expectedVersion: expectedVersion); + } + final TavernJsonObject held; + try { + held = _held(value, 'replaceVariables') as TavernJsonObject; + } on TavernScriptException catch (e) { + return Future.error(e); + } + return _call((b) => b.replaceVariables(contextId, target, held, expectedVersion: expectedVersion)); + } + + @override + Future updateVariablesWith( + String contextId, + TavernVariableTarget target, + FutureOr Function(TavernJsonObject value) updater, + ) => _call((b) => b.updateVariablesWith(contextId, target, updater)); + + @override + Future mutateVariables( + String contextId, + TavernVariableTarget target, + List changes, { + String? expectedVersion, + }) { + final held = List.of(changes); + return _call((b) => b.mutateVariables(contextId, target, held, expectedVersion: expectedVersion)); + } +} + +/// [value] as it is now: maps and lists copied all the way down, the rest +/// kept for the engine to judge. A cycle is not JSON. +Object? _held(Object? value, String operation, [Set? seen]) { + if (value is! Map && value is! List) return value; + final active = seen ?? Set.identity(); + if (!active.add(value!)) { + throw TavernScriptException( + code: TavernScriptErrorCode.invalidJson, + operation: operation, + message: 'The value contains a cycle.', + ); + } + try { + return switch (value) { + Map() => { + for (final MapEntry(:key, value: v) in value.entries) key: _held(v, operation, active), + }, + Map() => {for (final MapEntry(:key, value: v) in value.entries) key: _held(v, operation, active)}, + List() => [for (final v in value) _held(v, operation, active)], + _ => value, + }; + } finally { + active.remove(value); + } +} diff --git a/app/lib/tavern/tavern_preview.dart b/app/lib/tavern/tavern_preview.dart index 4d2753149e..89fa7bdb67 100644 --- a/app/lib/tavern/tavern_preview.dart +++ b/app/lib/tavern/tavern_preview.dart @@ -35,6 +35,10 @@ class PreviewTavern implements Tavern, TavernPreview { String _id(String prefix) => '$prefix-${++_seq}'; + /// Card scripts (P2 B1) are not simulated here: the bridge says so. + @override + late final TavernScriptBridge scripts = _PreviewScripts(); + // Library ------------------------------------------------------------------- /// The list as the engine gives it (T23): the long fields left out, which @@ -1806,3 +1810,119 @@ const _samples = [ greetings: ['「舰桥欢迎你,舰长。航线已经算好,还有三十七个小时抵达。要听个笑话打发时间吗?」'], ), ]; + +/// The preview's [Tavern.scripts]: it simulates none of the bridge, and says +/// so (simulated, no methods, no scopes); every call is refused as such, +/// and no events come. +class _PreviewScripts implements TavernScriptBridge { + TavernScriptHost? _host; + + Future _no(String operation) => Future.error( + TavernScriptException( + code: TavernScriptErrorCode.unsupportedCapability, + operation: operation, + message: 'The preview does not simulate card scripts.', + ), + ); + + @override + TavernScriptCapabilities get capabilities => TavernScriptCapabilities( + adapterVersion: 'preview', + methods: const {}, + scopes: const {}, + hostAttached: _host != null, + simulated: true, + ); + + @override + void attachHost(TavernScriptHost? host) => _host = host; + + @override + Future openContext(TavernScriptContextSpec spec) => _no('openContext'); + + @override + Future snapshot(String contextId) => _no('snapshot'); + + @override + Future closeContext(String contextId) => _no('closeContext'); + + @override + Future activateChat(String contextId) => _no('activateChat'); + + @override + Stream watchEvents({String? chatId}) => const Stream.empty(); + + @override + Future getChatMessages( + String contextId, + TavernMessageRange range, { + TavernScriptRole? role, + TavernHiddenFilter hidden = TavernHiddenFilter.all, + bool includeSwipes = false, + }) => _no('getChatMessages'); + + @override + Future setChatMessages( + String contextId, + List patches, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _no('setChatMessages'); + + @override + Future createChatMessages( + String contextId, + List messages, { + int? insertBefore, + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _no('createChatMessages'); + + @override + Future deleteChatMessages( + String contextId, + List messageIds, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _no('deleteChatMessages'); + + @override + Future rotateChatMessages( + String contextId, + int begin, + int middle, + int end, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _no('rotateChatMessages'); + + @override + Future selectSwipe( + String contextId, + TavernScriptMessageRef message, + int swipeId, { + TavernScriptWriteOptions options = const TavernScriptWriteOptions(), + }) => _no('selectSwipe'); + + @override + Future getVariables(String contextId, TavernVariableTarget target) => _no('getVariables'); + + @override + Future replaceVariables( + String contextId, + TavernVariableTarget target, + TavernJsonObject value, { + String? expectedVersion, + }) => _no('replaceVariables'); + + @override + Future updateVariablesWith( + String contextId, + TavernVariableTarget target, + FutureOr Function(TavernJsonObject value) updater, + ) => _no('updateVariablesWith'); + + @override + Future mutateVariables( + String contextId, + TavernVariableTarget target, + List changes, { + String? expectedVersion, + }) => _no('mutateVariables'); +} diff --git a/app/test/tavern/deferred_tavern_test.dart b/app/test/tavern/deferred_tavern_test.dart index dd3ea4873a..c15c452da3 100644 --- a/app/test/tavern/deferred_tavern_test.dart +++ b/app/test/tavern/deferred_tavern_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:dsh_app/tavern/deferred_tavern.dart'; import 'package:dsh_app/tavern/tavern_preview.dart'; @@ -25,6 +26,84 @@ void main() { expect(events.last, isA()); }); + test('card scripts: one bridge; before it opens it claims nothing; the host set early reaches it', () async { + final opening = Completer(); + final deferred = DeferredTavern(opening.future); + final bridge = deferred.scripts; + expect(identical(bridge, deferred.scripts), isTrue); + expect(bridge.capabilities.methods, isEmpty); + expect(bridge.capabilities.simulated, isFalse); + bridge.attachHost(_Host()); + expect(bridge.capabilities.hostAttached, isTrue); + final refused = bridge.getVariables('c', const TavernVariableTarget(scope: TavernVariableScope.global)); + + final preview = PreviewTavern(tick: Duration.zero); + opening.complete(preview); + await expectLater( + refused, + throwsA(isA().having((e) => e.code, 'code', TavernScriptErrorCode.unsupportedCapability)), + ); + // Open: what the preview says, with the host handed over. + expect(bridge.capabilities.simulated, isTrue); + expect(bridge.capabilities.hostAttached, isTrue); + expect(preview.scripts.capabilities.hostAttached, isTrue); + bridge.attachHost(null); + expect(preview.scripts.capabilities.hostAttached, isFalse); + }); + + test('card scripts reach the real engine: a global variable written, read back, and kept', () async { + final dir = await Directory.systemTemp.createTemp('deferred-scripts'); + addTearDown(() => dir.delete(recursive: true)); + const global = TavernVariableTarget(scope: TavernVariableScope.global); + final opening = Completer(); + final deferred = DeferredTavern(opening.future); + final context = deferred.scripts.openContext(const TavernScriptContextSpec(instanceId: 'frame')); + opening.complete(await openTavern(dir.path)); + + final id = (await context).id; + final value = { + 'hp': 7, + 'bag': ['key'], + }; + final saved = await deferred.scripts.replaceVariables(id, global, value); + // The engine took it as it was at the call. + (value['bag']! as List).add('map'); + expect(saved.value, { + 'hp': 7, + 'bag': ['key'], + }); + expect(deferred.scripts.capabilities.simulated, isFalse); + expect(deferred.scripts.capabilities.methods, contains('replaceVariables')); + await deferred.close(); + + // Opened again: still there. + final again = DeferredTavern(openTavern(dir.path)); + addTearDown(again.close); + final reopened = await again.scripts.openContext(const TavernScriptContextSpec(instanceId: 'frame')); + expect((await again.scripts.getVariables(reopened.id, global)).value, { + 'hp': 7, + 'bag': ['key'], + }); + }); + + test('card scripts before it opens: a value is copied at the call; a cycle is refused there', () async { + final opening = Completer(); + final deferred = DeferredTavern(opening.future); + const global = TavernVariableTarget(scope: TavernVariableScope.global); + final loop = {}; + loop['self'] = loop; + await expectLater( + deferred.scripts.replaceVariables('c', global, loop), + throwsA(isA().having((e) => e.code, 'code', TavernScriptErrorCode.invalidJson)), + ); + // A plain value waits, copied: the preview then refuses it as unsimulated. + final value = {'hp': 7}; + final waiting = deferred.scripts.replaceVariables('c', global, value); + value['hp'] = 0; + opening.complete(PreviewTavern(tick: Duration.zero)); + await expectLater(waiting, throwsA(isA())); + }); + test('the libraries and the display pass reach the engine once it opens (T20, T21)', () async { final preview = PreviewTavern(tick: Duration.zero); final chat = await preview.createChat('sample-innkeeper'); @@ -43,3 +122,9 @@ void main() { expect((await deferred.listLorebooks()).single.id, book.book.id); }); } + +/// A host that never answers: only its presence is looked at. +class _Host implements TavernScriptHost { + @override + Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/app/test/tavern/tavern_chat_recovery_test.dart b/app/test/tavern/tavern_chat_recovery_test.dart index 7b34f01583..3349febf5f 100644 --- a/app/test/tavern/tavern_chat_recovery_test.dart +++ b/app/test/tavern/tavern_chat_recovery_test.dart @@ -304,7 +304,7 @@ class _NoKeyDraft extends PreviewTavern { _NoKeyDraft() : super(tick: const Duration(milliseconds: 1)); @override - Stream impersonate(String chatId) => Stream.value(const TavernTurnFailed(TavernFailure.noKey)); + Stream impersonate(String chatId) => Stream.value(TavernTurnFailed(TavernFailure.noKey)); } /// The preview with a long chat: 150 messages on its path. From aa5b97357bbf5901dcbb2105353005208b099275 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 22:58:18 +0800 Subject: [PATCH 10/51] =?UTF-8?q?feat(tavern):=20=E5=8D=A1=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=20JSON=20=E9=80=9A=E9=81=93=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=9A=E8=AF=BB=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=92=8C=E5=8F=98=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TavernScriptWire 把页面发来的 {id, method, params} 交给 Tavern.scripts,回 {id, ok, result|error}。照引擎的接入说明:版本号原样是字符串、null 成员保留、 data 不是对象时 data 为 null 并给 rawData、所有拒绝(调用时抛的和之后的)都回 结构化错误,参数类型错按字段名报。这一步带 capabilities、开关 context、读消息、 读/整体替换/按路径改变量;写消息要等 host 那边一起做。updateVariablesWith 不走 通道(函数留在页面里:读、调一次、带版本替换)。测试用真引擎,请求和回复都过 一遍 JSON 文本。 --- app/lib/tavern/tavern_script_wire.dart | 305 +++++++++++++++++++ app/test/tavern/tavern_script_wire_test.dart | 219 +++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 app/lib/tavern/tavern_script_wire.dart create mode 100644 app/test/tavern/tavern_script_wire_test.dart diff --git a/app/lib/tavern/tavern_script_wire.dart b/app/lib/tavern/tavern_script_wire.dart new file mode 100644 index 0000000000..65cf6033bc --- /dev/null +++ b/app/lib/tavern/tavern_script_wire.dart @@ -0,0 +1,305 @@ +// What a card's script asks of the engine (P2 B1, Tavern.scripts) over its +// page's channel, and what it is told back: one JSON request +// {"id": …, "method": "getVariables", "params": {…}} +// and one JSON reply +// {"id": …, "ok": true, "result": …} or {"id": …, "ok": false, "error": {…}}. +// +// The rules the engine's App notes set for a wire (API-INTEGRATION, B1 §2): +// versions and revisions go out and come back as the opaque strings they +// are; a member present as null stays present; a message whose data is not +// an object reads data: null with its raw value beside it; every refusal — +// thrown at the call or later — comes back as a structured error, never as +// a channel failure. This first part carries reading (capabilities, +// contexts, messages) and variables; message writes need the host side and +// come with it. updateVariablesWith is not a wire method: a script's +// function stays in the page (read, call it once, replace with the version +// it read). + +import 'package:dsh_tavern/dsh_tavern.dart'; + +class TavernScriptWire { + TavernScriptWire(this.bridge); + + final TavernScriptBridge bridge; + + /// The reply to [request], always JSON; never throws. + Future> handle(Object? request) async { + final id = request is Map ? request['id'] : null; + try { + if (request is! Map) throw _bad('request', 'A request is a JSON object.'); + final method = request['method']; + if (method is! String) throw _bad('request', 'A request names its method.'); + final params = request['params'] ?? const {}; + if (params is! Map) throw _bad(method, 'params is a JSON object.'); + final p = _Params(method, params); + return {'id': id, 'ok': true, 'result': await _call(method, p)}; + } on TavernScriptException catch (e) { + return {'id': id, 'ok': false, 'error': _error(e)}; + } on Object catch (e) { + // Not the engine's own refusal: said as such, not dressed as one. + return { + 'id': id, + 'ok': false, + 'error': {'code': 'internal', 'message': '$e', 'committed': false}, + }; + } + } + + Future _call(String method, _Params p) async { + switch (method) { + case 'capabilities': + final c = bridge.capabilities; + return { + 'adapterVersion': c.adapterVersion, + 'methods': [...c.methods]..sort(), + 'scopes': [for (final s in c.scopes) s.name]..sort(), + 'hostAttached': c.hostAttached, + 'simulated': c.simulated, + }; + case 'openContext': + final context = await bridge.openContext( + TavernScriptContextSpec( + instanceId: p.string('instanceId'), + chatId: p.stringOrNull('chatId'), + characterId: p.stringOrNull('characterId'), + nodeId: p.stringOrNull('nodeId'), + script: p.has('script') ? _owner(p.object('script'), p.method) : null, + ), + ); + return _context(context); + case 'closeContext': + await bridge.closeContext(p.string('contextId')); + return null; + case 'getChatMessages': + final range = p.has('range') ? p.object('range') : const {}; + final page = await bridge.getChatMessages( + p.string('contextId'), + TavernMessageRange(first: _intOrNull(range, 'first', p.method), last: _intOrNull(range, 'last', p.method)), + role: p.has('role') ? _enum(TavernScriptRole.values, p.string('role'), 'role', p.method) : null, + hidden: p.has('hidden') + ? _enum(TavernHiddenFilter.values, p.string('hidden'), 'hidden', p.method) + : TavernHiddenFilter.all, + includeSwipes: p.boolOr('includeSwipes', false), + ); + return { + 'pathRevision': page.pathRevision, + 'totalPathLength': page.totalPathLength, + 'messages': [for (final m in page.messages) _message(m)], + }; + case 'getVariables': + return _snapshot(await bridge.getVariables(p.string('contextId'), _target(p.object('target'), p.method))); + case 'replaceVariables': + return _snapshot( + await bridge.replaceVariables( + p.string('contextId'), + _target(p.object('target'), p.method), + p.object('value'), + expectedVersion: p.stringOrNull('expectedVersion'), + ), + ); + case 'mutateVariables': + final mutation = await bridge.mutateVariables(p.string('contextId'), _target(p.object('target'), p.method), [ + for (final (i, c) in p.list('changes').indexed) _change(c, i, p.method), + ], expectedVersion: p.stringOrNull('expectedVersion')); + return { + 'snapshot': _snapshot(mutation.snapshot), + 'results': [ + for (final r in mutation.results) + {'kind': r.kind.name, 'path': r.pathTokens, 'existed': r.existed, 'changed': r.changed}, + ], + }; + } + throw TavernScriptException( + code: TavernScriptErrorCode.unsupportedCapability, + operation: method, + message: 'The page channel does not carry $method.', + ); + } +} + +TavernScriptException _bad(String operation, String message, {String? fieldPath}) => TavernScriptException( + code: TavernScriptErrorCode.invalidArgument, + operation: operation, + message: message, + fieldPath: fieldPath, +); + +Map _error(TavernScriptException e) => { + 'code': e.code.name, + 'operation': e.operation, + 'message': e.message, + 'committed': e.committed, + 'contextId': e.contextId, + 'nodeId': e.nodeId, + 'fieldPath': e.fieldPath, +}; + +/// A request's params, read strictly: the wrong type is the caller's error, +/// named by its field. +class _Params { + _Params(this.method, this._map); + + final String method; + final Map _map; + + bool has(String key) => _map.containsKey(key) && _map[key] != null; + + Object? _need(String key) { + if (!_map.containsKey(key)) throw _bad(method, '$key is missing.', fieldPath: key); + return _map[key]; + } + + String string(String key) => switch (_need(key)) { + final String s => s, + _ => throw _bad(method, '$key is a string.', fieldPath: key), + }; + + String? stringOrNull(String key) => switch (_map[key]) { + null => null, + final String s => s, + _ => throw _bad(method, '$key is a string.', fieldPath: key), + }; + + bool boolOr(String key, bool otherwise) => switch (_map[key]) { + null => otherwise, + final bool b => b, + _ => throw _bad(method, '$key is true or false.', fieldPath: key), + }; + + Map object(String key) => _asObject(_need(key), key, method); + + List list(String key) => switch (_need(key)) { + final List l => l, + _ => throw _bad(method, '$key is a list.', fieldPath: key), + }; +} + +Map _asObject(Object? value, String field, String method) { + if (value is! Map) throw _bad(method, '$field is a JSON object.', fieldPath: field); + final out = {}; + for (final MapEntry(:key, :value) in value.entries) { + if (key is! String) throw _bad(method, '$field has a key that is not a string.', fieldPath: field); + out[key] = value; + } + return out; +} + +int? _intOrNull(Map map, String key, String method) => switch (map[key]) { + null => null, + final int n => n, + _ => throw _bad(method, '$key is a whole number.', fieldPath: key), +}; + +T _enum(List values, String name, String field, String method) { + for (final v in values) { + if (v.name == name) return v; + } + throw _bad(method, '$field is one of ${values.map((v) => v.name).join(', ')}.', fieldPath: field); +} + +TavernScriptOwner _owner(Map json, String method) { + final p = _Params(method, json); + return TavernScriptOwner( + kind: _enum(TavernScriptOwnerKind.values, p.string('kind'), 'script.kind', method), + scriptId: p.string('scriptId'), + assetId: p.stringOrNull('assetId'), + ); +} + +TavernScriptMessageRef _ref(Map json, String method) { + final p = _Params(method, json); + return TavernScriptMessageRef(chatId: p.string('chatId'), nodeId: p.string('nodeId')); +} + +TavernVariableTarget _target(Map json, String method) { + final p = _Params(method, json); + return TavernVariableTarget( + scope: _enum(TavernVariableScope.values, p.string('scope'), 'target.scope', method), + characterId: p.stringOrNull('characterId'), + chatId: p.stringOrNull('chatId'), + messageId: _intOrNull(json, 'messageId', method), + messageRef: p.has('messageRef') ? _ref(p.object('messageRef'), method) : null, + scriptOwner: p.has('scriptOwner') ? _owner(p.object('scriptOwner'), method) : null, + ); +} + +/// One change; `value` present (even as null) is a value, absent is none. +TavernVariableChange _change(Object? json, int at, String method) { + final change = _asObject(json, 'changes[$at]', method); + final p = _Params(method, change); + final path = [ + for (final token in p.list('path')) + switch (token) { + final String s => s, + final int n when n >= 0 => n, + _ => throw _bad(method, 'A path step is a key or an index from 0.', fieldPath: 'changes[$at].path'), + }, + ]; + final kind = _enum(TavernVariableChangeKind.values, p.string('kind'), 'changes[$at].kind', method); + return change.containsKey('value') + ? TavernVariableChange(kind: kind, pathTokens: path, value: change['value']) + : TavernVariableChange(kind: kind, pathTokens: path); +} + +Map? _ownerJson(TavernScriptOwner? o) => + o == null ? null : {'kind': o.kind.name, 'scriptId': o.scriptId, 'assetId': o.assetId}; + +Map _refJson(TavernScriptMessageRef r) => {'chatId': r.chatId, 'nodeId': r.nodeId}; + +Map _context(TavernScriptContext c) => { + 'id': c.id, + 'engineEpoch': c.engineEpoch, + 'instanceId': c.instanceId, + 'chatId': c.chatId, + 'characterId': c.characterId, + 'nodeId': c.nodeId, + 'script': _ownerJson(c.script), +}; + +Map _targetJson(TavernVariableTarget t) => { + 'scope': t.scope.name, + 'characterId': t.characterId, + 'chatId': t.chatId, + 'messageId': t.messageId, + 'messageRef': t.messageRef == null ? null : _refJson(t.messageRef!), + 'scriptOwner': _ownerJson(t.scriptOwner), +}; + +Map _snapshot(TavernVariableSnapshot s) => { + 'target': _targetJson(s.resolvedTarget), + 'version': s.version, + 'value': s.value, +}; + +Map? _issue(TavernScriptShapeIssue? i) => + i == null ? null : {'fieldPath': i.fieldPath, 'actualKind': i.actualKind, 'message': i.message}; + +Map _message(TavernScriptMessage m) => { + 'messageId': m.messageId, + 'ref': _refJson(m.ref), + 'name': m.name, + 'role': m.role.name, + 'nativeRole': m.nativeRole.name, + 'message': m.message, + 'hidden': m.hidden, + 'speakerCharacterId': m.speakerCharacterId, + 'hasData': m.hasData, + 'data': m.data, + 'rawData': m.rawData, + 'dataIssue': _issue(m.dataIssue), + 'extra': m.extra, + 'swipeId': m.swipeId, + 'swipes': [ + for (final s in m.swipes) + { + 'ref': _refJson(s.ref), + 'swipeId': s.swipeId, + 'text': s.text, + 'hasData': s.hasData, + 'data': s.data, + 'rawData': s.rawData, + 'dataIssue': _issue(s.dataIssue), + 'info': s.info, + }, + ], +}; diff --git a/app/test/tavern/tavern_script_wire_test.dart b/app/test/tavern/tavern_script_wire_test.dart new file mode 100644 index 0000000000..5eb6465a5c --- /dev/null +++ b/app/test/tavern/tavern_script_wire_test.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dsh_app/tavern/tavern_script_wire.dart'; +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The card's page side: a request as JSON text, the reply read back from +/// JSON text — what crosses the channel. +Future> _ask(TavernScriptWire wire, String method, [Map? params]) async { + final request = jsonDecode(jsonEncode({'id': method, 'method': method, 'params': ?params})); + final reply = await wire.handle(request); + return jsonDecode(jsonEncode(reply)) as Map; +} + +Object? _result(Map reply) { + expect(reply['ok'], isTrue, reason: '${reply['error']}'); + return reply['result']; +} + +Map _error(Map reply) { + expect(reply['ok'], isFalse); + return reply['error']! as Map; +} + +void main() { + late Directory dir; + late Tavern tavern; + late TavernScriptWire wire; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('script-wire'); + tavern = await openTavern(dir.path); + wire = TavernScriptWire(tavern.scripts); + }); + tearDown(() async { + await tavern.close(); + await dir.delete(recursive: true); + }); + + test('variables over the channel: replaced, changed by path, read back; null stays a member', () async { + final caps = _result(await _ask(wire, 'capabilities'))! as Map; + expect(caps['simulated'], isFalse); + expect(caps['methods'], containsAll(['getVariables', 'replaceVariables', 'mutateVariables'])); + expect(caps['scopes'], contains('global')); + + final context = _result(await _ask(wire, 'openContext', {'instanceId': 'frame'}))! as Map; + final id = context['id']! as String; + const global = {'scope': 'global'}; + + final first = + _result( + await _ask(wire, 'replaceVariables', { + 'contextId': id, + 'target': global, + 'value': { + 'hp': 7, + 'bag': ['key'], + 'note': null, + }, + }), + )! + as Map; + expect(first['value'], { + 'hp': 7, + 'bag': ['key'], + 'note': null, + }); + expect(first['version'], isA()); // opaque, as given + expect((first['target']! as Map)['scope'], 'global'); + + final changed = + _result( + await _ask(wire, 'mutateVariables', { + 'contextId': id, + 'target': global, + 'changes': [ + { + 'kind': 'set', + 'path': ['bag', 1], + 'value': 'map', + }, + { + 'kind': 'insertIfAbsent', + 'path': ['hp'], + 'value': 1, + }, + { + 'kind': 'remove', + 'path': ['note'], + }, + ], + }), + )! + as Map; + expect(changed['results'], [ + { + 'kind': 'set', + 'path': ['bag', 1], + 'existed': false, + 'changed': true, + }, + { + 'kind': 'insertIfAbsent', + 'path': ['hp'], + 'existed': true, + 'changed': false, + }, + { + 'kind': 'remove', + 'path': ['note'], + 'existed': true, + 'changed': true, + }, + ]); + final read = _result(await _ask(wire, 'getVariables', {'contextId': id, 'target': global}))! as Map; + expect(read['value'], { + 'hp': 7, + 'bag': ['key', 'map'], + }); + + // The version it was read at is stale now: refused, nothing written. + final stale = _error( + await _ask(wire, 'replaceVariables', { + 'contextId': id, + 'target': global, + 'value': {'hp': 0}, + 'expectedVersion': first['version'], + }), + ); + expect(stale['code'], 'revisionConflict'); + expect(stale['committed'], isFalse); + expect(((await _ask(wire, 'getVariables', {'contextId': id, 'target': global}))['result']! as Map)['value'], { + 'hp': 7, + 'bag': ['key', 'map'], + }); + }); + + test("a chat's messages over the channel, numbered along the path", () async { + final card = await File('assets/tavern/sample_innkeeper.json').readAsBytes(); + final character = await tavern.importCard(card, fileName: 'sample_innkeeper.json'); + final chat = await tavern.createChat(character.id); + final context = + _result(await _ask(wire, 'openContext', {'instanceId': 'frame', 'chatId': chat}))! as Map; + final page = + _result(await _ask(wire, 'getChatMessages', {'contextId': context['id'], 'range': {}}))! + as Map; + expect(page['pathRevision'], isA()); + final messages = page['messages']! as List; + expect(messages, hasLength(page['totalPathLength'])); + final greeting = messages.first as Map; + expect(greeting['messageId'], 0); + expect(greeting['role'], 'assistant'); + expect(greeting['nativeRole'], 'character'); + expect(greeting['ref'], {'chatId': chat, 'nodeId': isA()}); + expect(greeting['message'], isNotEmpty); + // No data yet: an empty object, not null. + expect(greeting['hasData'], isFalse); + expect(greeting['data'], {}); + expect(greeting.containsKey('speakerCharacterId'), isTrue); + }); + + test('a wrong request is told why, as an error, not a broken channel', () async { + final id = + (_result(await _ask(wire, 'openContext', {'instanceId': 'frame'}))! as Map)['id']! as String; + Future> refused(String method, [Map? params]) async => + _error(await _ask(wire, method, params)); + + expect((await refused('runScript'))['code'], 'unsupportedCapability'); + expect((await refused('getVariables', {'contextId': id}))['fieldPath'], 'target'); + expect( + (await refused('getVariables', { + 'contextId': id, + 'target': {'scope': 'everywhere'}, + }))['fieldPath'], + 'target.scope', + ); + final notObject = await refused('replaceVariables', { + 'contextId': id, + 'target': {'scope': 'global'}, + 'value': [1], + }); + expect((notObject['code'], notObject['fieldPath']), ('invalidArgument', 'value')); + // A version is the string it was handed out as, never a number. + final numeric = await refused('replaceVariables', { + 'contextId': id, + 'target': {'scope': 'global'}, + 'value': {}, + 'expectedVersion': 3, + }); + expect(numeric['fieldPath'], 'expectedVersion'); + expect( + (await refused('mutateVariables', { + 'contextId': id, + 'target': {'scope': 'global'}, + 'changes': [ + { + 'kind': 'set', + 'path': [-1], + 'value': 1, + }, + ], + }))['fieldPath'], + 'changes[0].path', + ); + // The engine's own refusals come through with their code. + expect( + (await refused('getVariables', { + 'contextId': 'nobody', + 'target': {'scope': 'global'}, + }))['code'], + isIn(['noContext', 'closedContext']), + ); + // Not even an object: still an answer. + final reply = await wire.handle('hello'); + expect(reply['ok'], isFalse); + expect(jsonEncode(reply), isA()); + }); +} From 44f21ccc67d3fd648fd95fc2c9ab5a08884ed383 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 23:32:20 +0800 Subject: [PATCH 11/51] =?UTF-8?q?feat(tavern):=20=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=8D=A1=E8=84=9A=E6=9C=AC=E8=83=BD=E8=B0=83?= =?UTF-8?q?=E5=BC=95=E6=93=8E=EF=BC=88dsh.call=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面里 await dsh.call(method, params) 得到引擎的回答(结果,或带 code/committed/ fieldPath 的 Error);经 DshTavern 通道发 {type:'call'},答回 dsh.answer。 TavernCanvasCalls 给每个画布开这段对话自己的脚本 context(第一次要用时开, 引擎说已关就下次重开,页面走了就关),再交给 TavernScriptWire;群聊不猜成员。 渲染进程换过后,旧页面的回答不送到新页面。JS 在 headless Edge 里核过: 发出的格式、成功、失败、未知和重复回答都对;Dart 侧用真引擎测了开一次、 复用、关了再开。 --- app/lib/tavern/tavern_canvas.dart | 16 ++++ app/lib/tavern/tavern_canvas_calls.dart | 74 ++++++++++++++++++ app/lib/tavern/tavern_script_wire.dart | 5 +- app/lib/ui/tavern/tavern_canvas_view.dart | 37 +++++++-- app/lib/ui/tavern/tavern_chat_page.dart | 11 +++ app/test/tavern/tavern_canvas_calls_test.dart | 76 +++++++++++++++++++ app/test/tavern/tavern_canvas_test.dart | 23 +++++- 7 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 app/lib/tavern/tavern_canvas_calls.dart create mode 100644 app/test/tavern/tavern_canvas_calls_test.dart diff --git a/app/lib/tavern/tavern_canvas.dart b/app/lib/tavern/tavern_canvas.dart index 266804f9dd..50cb8b187e 100644 --- a/app/lib/tavern/tavern_canvas.dart +++ b/app/lib/tavern/tavern_canvas.dart @@ -261,7 +261,23 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple return m; } var stream=null; + // dsh.call(method, params): a Promise of what the engine answers (the + // result, or an Error carrying code/committed/fieldPath). + var calls={},callSeq=0; window.dsh={ + call:function(method,params){ + return new Promise(function(ok,no){ + var id=++callSeq;calls[id]={ok:ok,no:no}; + post({type:'call',id:id,method:method,params:params===undefined?{}:params}); + }); + }, + answer:function(r){ + var c=r&&calls[r.id];if(!c)return;delete calls[r.id]; + if(r.ok){c.ok(r.result);return;} + var x=r.error||{};var e=new Error(x.message||x.code||'failed'); + e.code=x.code;e.committed=!!x.committed;e.fieldPath=x.fieldPath==null?null:x.fieldPath; + c.no(e); + }, render:function(list){ var stay=nearBottom()||!chat.firstChild; if(stream){stream.remove();stream=null;} diff --git a/app/lib/tavern/tavern_canvas_calls.dart b/app/lib/tavern/tavern_canvas_calls.dart new file mode 100644 index 0000000000..a5e08696fd --- /dev/null +++ b/app/lib/tavern/tavern_canvas_calls.dart @@ -0,0 +1,74 @@ +// A canvas page's calls into the engine: what a card's script asks with +// `await dsh.call('getVariables', {target: {scope: 'chat'}})`. Each goes +// through [TavernScriptWire] within this chat's own script context, which is +// opened on the first call that needs one and closed with the page; a call +// may still name another context it opened itself. + +import 'package:dsh_tavern/dsh_tavern.dart'; + +import 'tavern_script_wire.dart'; + +class TavernCanvasCalls { + TavernCanvasCalls(this._bridge, {required this.chatId, required this.characterId, this.instanceId = 'canvas'}); + + final TavernScriptBridge _bridge; + final String chatId; + + /// The chat's character as it is when the context opens; null for a group + /// (the engine does not guess a member for it). + final String? Function() characterId; + + /// Who the page is to the engine: its running instance, not a saved key. + final String instanceId; + + late final _wire = TavernScriptWire(_bridge); + Future? _context; + + static const _own = {'capabilities', 'openContext'}; + + Future _open() async => (await _bridge.openContext( + TavernScriptContextSpec(instanceId: instanceId, chatId: chatId, characterId: characterId()), + )).id; + + /// The reply to [request], always JSON; never throws. + Future> handle(Object? request) async { + if (request is! Map || request['method'] is! String || _own.contains(request['method'])) { + return _wire.handle(request); + } + final params = request['params'] ?? const {}; + if (params is! Map || params.containsKey('contextId')) return _wire.handle(request); + final String context; + try { + context = await (_context ??= _open()); + } on TavernScriptException catch (e) { + _context = null; + return {'id': request['id'], 'ok': false, 'error': tavernScriptErrorJson(e)}; + } on Object catch (e) { + _context = null; + return { + 'id': request['id'], + 'ok': false, + 'error': {'code': 'internal', 'message': '$e', 'committed': false}, + }; + } + final reply = await _wire.handle({ + ...request, + 'params': {...params, 'contextId': context}, + }); + // The engine let it go (reopened, closed): the next call opens anew. + if (reply['error'] case {'code': 'closedContext' || 'noContext'}) _context = null; + return reply; + } + + /// The page is gone: its context goes with it. + Future close() async { + final context = _context; + _context = null; + if (context == null) return; + try { + await _bridge.closeContext(await context); + } on Object { + // Never opened, or already closed: nothing to release. + } + } +} diff --git a/app/lib/tavern/tavern_script_wire.dart b/app/lib/tavern/tavern_script_wire.dart index 65cf6033bc..4da4f06616 100644 --- a/app/lib/tavern/tavern_script_wire.dart +++ b/app/lib/tavern/tavern_script_wire.dart @@ -34,7 +34,7 @@ class TavernScriptWire { final p = _Params(method, params); return {'id': id, 'ok': true, 'result': await _call(method, p)}; } on TavernScriptException catch (e) { - return {'id': id, 'ok': false, 'error': _error(e)}; + return {'id': id, 'ok': false, 'error': tavernScriptErrorJson(e)}; } on Object catch (e) { // Not the engine's own refusal: said as such, not dressed as one. return { @@ -124,7 +124,8 @@ TavernScriptException _bad(String operation, String message, {String? fieldPath} fieldPath: fieldPath, ); -Map _error(TavernScriptException e) => { +/// A refusal as the page reads it. +Map tavernScriptErrorJson(TavernScriptException e) => { 'code': e.code.name, 'operation': e.operation, 'message': e.message, diff --git a/app/lib/ui/tavern/tavern_canvas_view.dart b/app/lib/ui/tavern/tavern_canvas_view.dart index 4009f776cb..1e8f77031e 100644 --- a/app/lib/ui/tavern/tavern_canvas_view.dart +++ b/app/lib/ui/tavern/tavern_canvas_view.dart @@ -23,27 +23,36 @@ import '../web/web_view_guard.dart'; import 'tavern_ink.dart'; /// What the canvas is handed: the shown messages; the reply being written -/// (its speaker's name and its HTML, null when none) as it changes; and what -/// to do when a message is held. +/// (its speaker's name and its HTML, null when none) as it changes; what to +/// do when a message is held; and who answers the page's dsh.call (a JSON +/// request in, a JSON reply out), if anyone. typedef TavernCanvasBuilder = Widget Function({ required List entries, required ValueListenable<(String, String)?> streaming, required void Function(String nodeId) onMenu, + Future> Function(Object? request)? onCall, }); /// Builds the canvas. Tests swap it. final tavernCanvasBuilderProvider = Provider( (ref) => - ({required entries, required streaming, required onMenu}) => - TavernCanvasView(entries: entries, streaming: streaming, onMenu: onMenu), + ({required entries, required streaming, required onMenu, onCall}) => + TavernCanvasView(entries: entries, streaming: streaming, onMenu: onMenu, onCall: onCall), ); class TavernCanvasView extends ConsumerStatefulWidget { - const TavernCanvasView({super.key, required this.entries, required this.streaming, required this.onMenu}); + const TavernCanvasView({ + super.key, + required this.entries, + required this.streaming, + required this.onMenu, + this.onCall, + }); final List entries; final ValueListenable<(String, String)?> streaming; final void Function(String nodeId) onMenu; + final Future> Function(Object? request)? onCall; @override ConsumerState createState() => _TavernCanvasViewState(); @@ -159,9 +168,27 @@ class _TavernCanvasViewState extends ConsumerState { _onStream(); case 'menu': if (data['node'] case final String node when node.isNotEmpty) widget.onMenu(node); + case 'call': + unawaited(_answer(data)); } } + /// A dsh.call from the page, answered to the page that asked (not to one + /// built since, after the renderer went). + Future _answer(Map request) async { + final controller = _controller; + final call = widget.onCall; + final reply = call == null + ? { + 'id': request['id'], + 'ok': false, + 'error': {'code': 'unsupportedCapability', 'message': 'Nothing answers here.', 'committed': false}, + } + : await call(request); + if (!mounted || controller == null || !identical(controller, _controller)) return; + await controller.runJavaScript('window.dsh&&dsh.answer(${jsonEncode(reply)})').catchError((_) {}); + } + /// The messages, when they differ from what the page shows. void _render() { final controller = _controller; diff --git a/app/lib/ui/tavern/tavern_chat_page.dart b/app/lib/ui/tavern/tavern_chat_page.dart index 0422410067..6b0b1e7c8c 100644 --- a/app/lib/ui/tavern/tavern_chat_page.dart +++ b/app/lib/ui/tavern/tavern_chat_page.dart @@ -25,6 +25,7 @@ import '../../tavern/tavern_preview.dart'; import '../../tavern/tavern_card_theme.dart'; import '../../tavern/tavern_islands.dart'; import '../../tavern/tavern_canvas.dart'; +import '../../tavern/tavern_canvas_calls.dart'; import '../../tavern/tavern_components.dart' show tavernComponentTagsOut; import '../../tavern/tavern_jump.dart'; import '../../tavern/tavern_markers.dart'; @@ -154,6 +155,13 @@ class _TavernChatPageState extends ConsumerState { final _canvasStream = ValueNotifier<(String, String)?>(null); bool _canvasOn = false; + /// A card's script on the canvas asking the engine (dsh.call): in this + /// chat's own script context, opened on its first call. + TavernCanvasCalls? _calls; + String? _canvasCharacter; + TavernCanvasCalls get _canvasCalls => + _calls ??= TavernCanvasCalls(_tavern.scripts, chatId: widget.chatId, characterId: () => _canvasCharacter); + /// The chat's character (or a group's title), and the names of all. String _canvasSpeaker = ''; Map _canvasNames = const {}; @@ -248,6 +256,7 @@ class _TavernChatPageState extends ConsumerState { _live.removeListener(_feedCanvas); _live.dispose(); _canvasStream.dispose(); + unawaited(_calls?.close()); _stopThinking(); _thinking.dispose(); _voice.removeListener(_reportVoice); @@ -1275,6 +1284,7 @@ class _TavernChatPageState extends ConsumerState { _canvasOn = canvas; _canvasSpeaker = name; _canvasNames = names; + _canvasCharacter = group == null ? characterId : null; if (canvas) _feedCanvas(); // The pages and the canvas read each message as shown, through the // display regex; the bubble and novel rows ask for their own. @@ -1433,6 +1443,7 @@ class _TavernChatPageState extends ConsumerState { final m = messages?.where((m) => m.nodeId == node).firstOrNull; if (m != null) unawaited(_menu(m, last: m.nodeId == lastCharacter)); }, + onCall: _canvasCalls.handle, ) : NotificationListener( onNotification: (n) { diff --git a/app/test/tavern/tavern_canvas_calls_test.dart b/app/test/tavern/tavern_canvas_calls_test.dart new file mode 100644 index 0000000000..7e7f094f14 --- /dev/null +++ b/app/test/tavern/tavern_canvas_calls_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:dsh_app/tavern/tavern_canvas_calls.dart'; +import 'package:dsh_app/tavern/tavern_preview.dart'; +import 'package:dsh_tavern/dsh_tavern.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test("a page's calls run in its chat's own context, opened once, and closed with it", () async { + final dir = await Directory.systemTemp.createTemp('canvas-calls'); + final tavern = await openTavern(dir.path); + addTearDown(() async { + await tavern.close(); + await dir.delete(recursive: true); + }); + final card = await File('assets/tavern/sample_innkeeper.json').readAsBytes(); + final character = await tavern.importCard(card, fileName: 'sample_innkeeper.json'); + final chat = await tavern.createChat(character.id); + final calls = TavernCanvasCalls(tavern.scripts, chatId: chat, characterId: () => character.id); + + // No context named: this chat's own is used. + final saved = await calls.handle({ + 'id': 1, + 'method': 'replaceVariables', + 'params': { + 'target': {'scope': 'chat'}, + 'value': {'met': true}, + }, + }); + expect(saved['ok'], isTrue, reason: '${saved['error']}'); + final target = (saved['result']! as Map)['target']! as Map; + expect(target['chatId'], chat); + final read = await calls.handle({ + 'id': 2, + 'method': 'getVariables', + 'params': { + 'target': {'scope': 'chat'}, + }, + }); + expect((read['result']! as Map)['value'], {'met': true}); + final messages = await calls.handle({'id': 3, 'method': 'getChatMessages', 'params': {}}); + expect(((messages['result']! as Map)['messages']! as List).first, containsPair('role', 'assistant')); + // Asking what is there needs no context. + expect((await calls.handle({'id': 4, 'method': 'capabilities'}))['ok'], isTrue); + + // Closed with the page; a later call opens a new one and still works. + await calls.close(); + final again = await calls.handle({ + 'id': 5, + 'method': 'getVariables', + 'params': { + 'target': {'scope': 'chat'}, + }, + }); + expect((again['result']! as Map)['value'], {'met': true}); + await calls.close(); + }); + + test('no context to be had (the preview): told so; asked again, it tries again', () async { + final preview = PreviewTavern(tick: Duration.zero); + final chat = await preview.createChat('sample-innkeeper'); + final calls = TavernCanvasCalls(preview.scripts, chatId: chat, characterId: () => 'sample-innkeeper'); + for (final id in [1, 2]) { + final reply = await calls.handle({ + 'id': id, + 'method': 'getVariables', + 'params': { + 'target': {'scope': 'chat'}, + }, + }); + expect(reply['id'], id); + expect((reply['error']! as Map)['code'], 'unsupportedCapability'); + } + await calls.close(); + }); +} diff --git a/app/test/tavern/tavern_canvas_test.dart b/app/test/tavern/tavern_canvas_test.dart index 78fdc5b131..c3f168be85 100644 --- a/app/test/tavern/tavern_canvas_test.dart +++ b/app/test/tavern/tavern_canvas_test.dart @@ -23,6 +23,7 @@ class _Canvas { List entries = const []; ValueListenable<(String, String)?>? streaming; void Function(String nodeId)? onMenu; + Future> Function(Object? request)? onCall; var builds = 0; } @@ -55,11 +56,17 @@ Future<(PreviewTavern, String, _Canvas)> _chat( ProviderScope( overrides: [ tavernProvider.overrideWith((ref) => tavern), - tavernCanvasBuilderProvider.overrideWithValue(({required entries, required streaming, required onMenu}) { + tavernCanvasBuilderProvider.overrideWithValue(({ + required entries, + required streaming, + required onMenu, + onCall, + }) { canvas ..entries = entries ..streaming = streaming ..onMenu = onMenu + ..onCall = onCall ..builds += 1; return const SizedBox.expand(key: ValueKey('fake-canvas')); }), @@ -165,6 +172,20 @@ void main() { expect(narration.html, '

雨停了。

\n

"进来吧," 她说。

'); expect(canvas.streaming!.value, isNull); + // The page's dsh.call is answered: here the preview, which says it + // simulates no card scripts. + final reply = (await tester.runAsync( + () => canvas.onCall!({ + 'id': 1, + 'method': 'getVariables', + 'params': { + 'target': {'scope': 'chat'}, + }, + }), + ))!; + expect(reply['id'], 1); + expect((reply['error']! as Map)['code'], 'unsupportedCapability'); + // Holding a message on the canvas opens the app's own menu for it. canvas.onMenu!(narration.nodeId); await tester.pumpAndSettle(); From 40b4fb242a94e421cee98665b3ecab3b0c38f8e4 Mon Sep 17 00:00:00 2001 From: chimney Date: Thu, 12 Nov 2026 23:41:00 +0800 Subject: [PATCH 12/51] =?UTF-8?q?feat(tavern):=20=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=95=99=E4=B8=80=E4=BB=BD=E5=8F=98=E9=87=8F?= =?UTF-8?q?=EF=BC=8C=E9=85=92=E9=A6=86=E5=8A=A9=E6=89=8B=E5=BC=8F=E7=9A=84?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20getVariables=20/=20replaceVariables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照 P2 §4.3:页面先取 snapshot(wire 新增 snapshot:路径消息和 context 看得到的 global/character/chat/最新消息变量,带 lastSeq),同步 getter 读这份副本(返回 拷贝;message 可按楼层号,负数从末尾数)。replaceVariables 立刻读得到新值, 引擎回来后换成它实际存下的(带版本),被拒就退回;同一 scope 只认最后一次写。 渲染到带 '] + .join('\n'); final policy = noScripts || offline ? [ if (offline) "default-src 'none'", @@ -235,6 +242,7 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple .mes[data-streaming] .mes_text>:last-child::after,.mes[data-streaming] .mes_text:empty::after{content:"\\25CF";color:var(--dsh-muted);margin-left:3px;font-size:.6em;vertical-align:middle;}
+$preload "']); + expect(withLibs, contains('')); + expect(withLibs, contains(r'var s="<\/script>"')); + expect(withLibs.indexOf('window.__lib=1'), lessThan(withLibs.indexOf('window.dsh={'))); + expect(page(noScripts: true, libraries: ['window.__lib=1']), isNot(contains('window.__lib=1'))); // 酒馆助手's page API, from the page's own copy (checked in Edge). for (final name in ['getVariables', 'replaceVariables', 'getChatMessages', 'eventOn', 'tavern_events']) { expect(open, contains('if(!window.$name)window.$name=')); From 6be31b22664f624e5a05095e971707498219a222 Mon Sep 17 00:00:00 2001 From: chimney Date: Fri, 13 Nov 2026 03:18:12 +0800 Subject: [PATCH 23/51] =?UTF-8?q?feat(tavern):=20=E5=8D=A1=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=83=BD=E9=97=AE=E8=87=AA=E5=B7=B1=E5=9C=A8=E7=AC=AC?= =?UTF-8?q?=E5=87=A0=E6=A5=BC=EF=BC=9B=E4=BF=AE=E5=A5=BD=E3=80=8C=E4=B8=8D?= =?UTF-8?q?=E8=81=94=E7=BD=91=E3=80=8D=E6=97=B6=E5=8D=A1=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=85=A8=E8=A2=AB=E6=8B=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画布页按酒馆助手公开文档补上 getLastMessageId、getCurrentMessageId、 getIframeName、getMessageId,楼层号和 getChatMessages 一致(按节点找, 备选也认;长对话只载入一部分时也是真楼层)。消息里的内联脚本重建时带上 sourceURL dsh-mes-.js,调用栈上最近的那段就是它所在的消息,所以在 定时器、事件、await 之后也能问;外链脚本首次运行时和内联事件也能找到; 不在消息里调用会抛错,和文档一样。新画出来的带脚本消息若副本里还没有, 先重读快照再跑它的脚本。 修:「不联网」开着、又允许运行卡脚本时,script-src 里同时有 nonce 和 'unsafe-inline',按 CSP 规则后者失效,卡自己的脚本和 onclick 全被拦。 现在这种情况不带 nonce(外链脚本仍被 default-src 'none' 拦着);画布和 小岛两处都改了。Edge 里核过。 --- app/lib/tavern/tavern_canvas.dart | 60 ++++++++++++++++++++++-- app/lib/tavern/tavern_islands.dart | 4 +- app/test/tavern/tavern_canvas_test.dart | 9 ++++ app/test/tavern/tavern_islands_test.dart | 3 +- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/app/lib/tavern/tavern_canvas.dart b/app/lib/tavern/tavern_canvas.dart index 942f777fd7..4572004594 100644 --- a/app/lib/tavern/tavern_canvas.dart +++ b/app/lib/tavern/tavern_canvas.dart @@ -205,7 +205,9 @@ String tavernCanvasDocument({ if (offline) 'media-src data: blob:', if (offline) 'font-src data:', if (offline) "style-src 'unsafe-inline' data:", - if (noScripts) "script-src 'nonce-$nonce'" else "script-src 'unsafe-inline' 'unsafe-eval' 'nonce-$nonce'", + // With scripts on, no nonce: next to one, 'unsafe-inline' counts for + // nothing and a card's own scripts and handlers would be refused. + if (noScripts) "script-src 'nonce-$nonce'" else "script-src 'unsafe-inline' 'unsafe-eval'", if (noScripts) "script-src-attr 'none'", ].join('; ') : null; @@ -253,7 +255,12 @@ $preload function inert(s){var t=(s.getAttribute('type')||'').trim().toLowerCase();return t!==''&&!/^(text|application)[/](java|ecma)script\$|^module\$/.test(t);} // innerHTML leaves '); + final ours = themed.indexOf(''); + final theirs = themed.indexOf(''), + ); final json = jsonDecode( tavernCanvasJson(const [ diff --git a/app/test/tavern/tavern_st_theme_test.dart b/app/test/tavern/tavern_st_theme_test.dart index 266a571491..54fec7f677 100644 --- a/app/test/tavern/tavern_st_theme_test.dart +++ b/app/test/tavern/tavern_st_theme_test.dart @@ -97,6 +97,32 @@ void main() { expect(TavernStTheme.fromJson(jsonDecode(jsonEncode(theme.toJson()))), theme); }); + test('its custom CSS is kept as written, for the canvas, and can be let go', () { + const css = '#chat .mes { border-left: 3px solid gold; }\n.mes_text q { color: tomato; }'; + final theme = tavernStThemeOf({..._dark, 'custom_css': css}, fileName: 'x')!; + expect((theme.customCss, theme.useCss, theme.canvasCss), (css, true, css)); + expect(tavernStThemeOf(_dark, fileName: 'x')!.customCss, isNull); // '' is none + expect(tavernStThemeOf({..._dark, 'custom_css': ' \n'}, fileName: 'x')!.customCss, isNull); + // CSS alone is a theme too. + final cssOnly = tavernStThemeOf({ + 'main_text_color': 'rgba(1,1,1,1)', + 'quote_text_color': 'none', + 'custom_css': css, + }, fileName: 'x')!; + expect((cssOnly.quote, cssOnly.italics, cssOnly.customCss), (null, null, css)); + // Let go: the colours stay, the canvas gets none; kept across a restart. + final off = theme.withUseCss(false); + expect((off.quote, off.customCss, off.canvasCss), (theme.quote, css, '')); + expect(TavernStTheme.fromJson(jsonDecode(jsonEncode(off.toJson()))), off); + expect(TavernStTheme.fromJson(jsonDecode(jsonEncode(theme.toJson()))), theme); + // One kept before there was CSS reads as it was. + expect( + TavernStTheme.fromJson({'name': 'Old', 'quote': 0xE18A24, 'italics': null}), + const TavernStTheme(name: 'Old', quote: 0xE18A24), + ); + expect(TavernStTheme.fromJson({'name': 'Empty', 'quote': null, 'italics': null}), isNull); + }); + test('its colours are made readable on the page, hue kept', () { final theme = tavernStThemeOf(_dark, fileName: 'x')!; const white = Color(0xFFFFFFFF); @@ -124,6 +150,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('换成了 ST 主题「Dark V 1.0」的配色'), findsOneWidget); expect(find.text('ST 主题:Dark V 1.0'), findsOneWidget); + // No custom CSS in this one: nothing to switch. + expect(find.byKey(const ValueKey('tavern-st-theme-css')), findsNothing); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString(TavernSettingsController.paletteKey), 'st'); expect(prefs.getString(TavernStThemeController.key), contains('Dark V 1.0')); @@ -142,4 +170,38 @@ void main() { expect(prefs.getString(TavernSettingsController.paletteKey), 'ember'); expect(prefs.getString(TavernStThemeController.key), isNull); }); + + testWidgets('a theme with custom CSS: on for the canvas, and can be turned off, colours kept', (tester) async { + const css = '#chat .mes { border-left: 3px solid gold; }'; + await _settings(tester, [ + TavernChatFile(fileName: 'Gold.json', bytes: _bytes({..._dark, 'name': 'Gold', 'custom_css': css})), + ]); + await _show(tester, 'tavern-st-theme-import'); + await tester.tap(find.byKey(const ValueKey('tavern-st-theme-import'))); + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf(tester.element(find.byType(TavernSettingsPage))); + expect(container.read(tavernStThemeProvider)!.canvasCss, css); + + await _show(tester, 'tavern-st-theme-css'); + expect(find.text('用它的自定义 CSS'), findsOneWidget); + expect( + tester + .widget( + find.descendant(of: find.byKey(const ValueKey('tavern-st-theme-css')), matching: find.byType(Switch)), + ) + .value, + isTrue, + ); + await tester.tap(find.byKey(const ValueKey('tavern-st-theme-css'))); + await tester.pumpAndSettle(); + final off = container.read(tavernStThemeProvider)!; + expect((off.useCss, off.canvasCss, off.quote), (false, '', 0xE18A24)); + final prefs = await SharedPreferences.getInstance(); + expect(jsonDecode(prefs.getString(TavernStThemeController.key)!), containsPair('useCss', false)); + expect(prefs.getString(TavernSettingsController.paletteKey), 'st'); + }); } From 224cef8443422358057ade215bda19e74187f740 Mon Sep 17 00:00:00 2001 From: chimney Date: Fri, 13 Nov 2026 04:03:39 +0800 Subject: [PATCH 26/51] =?UTF-8?q?feat(tavern):=20=E6=95=B4=E9=A1=B5?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BB=99=E8=AF=BB=E5=B1=8F=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=92=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计稿 §12:读屏按消息顺序读,每条先读说话人;组件有标签。 - 每条消息是一个 article,以说话人为名;正在写的那条标 aria-busy,不跟着 流式一个字一个字念。 - 回复写完进来时,只在一个看不见的 aria-live 区域说一句「林晚回复了」,不把 整段读出来;第一次画出和切分支不说。 - 信、物品、线索、短信、掷骰组件是 group,以头部的内容为名(标题、日期、 点数、结果),头部本身对读屏隐藏,不念两遍;提示和小游戏是 note;✦、▷ 和流式小圆点这种装饰不读。卡自己写了 role 或 aria-label 的不覆盖。 在 Edge 里读了无障碍树核对,外观不变。 --- app/lib/l10n/app_en.arb | 1 + app/lib/l10n/app_zh.arb | 8 +++ app/lib/l10n/gen/app_localizations.dart | 6 ++ app/lib/l10n/gen/app_localizations_en.dart | 5 ++ app/lib/l10n/gen/app_localizations_zh.dart | 5 ++ app/lib/l10n/parts/tavern_en.arb | 2 + app/lib/l10n/parts/tavern_zh.arb | 2 + app/lib/tavern/tavern_canvas.dart | 80 ++++++++++++++++------ app/lib/ui/tavern/tavern_canvas_view.dart | 1 + app/test/tavern/tavern_canvas_test.dart | 13 ++++ 10 files changed, 101 insertions(+), 22 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index c367fe04a4..c9fa2837f4 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -6367,6 +6367,7 @@ "tavernRollSuccess": "Success", "tavernRollFailure": "Failure", "tavernGameHere": "A mini-game goes here; it can't be played here yet", + "tavernCanvasReplied": "{name} replied", "tavernRegexEdit": "Edit rule", "tavernRegexFieldName": "Name", "tavernRegexFieldFindHelp": "SillyTavern's form: /pattern/flags, e.g. /(hi|bye)/gi", diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 26c23318b8..e045c0df8d 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -12902,6 +12902,14 @@ "tavernRollSuccess": "成功", "tavernRollFailure": "失败", "tavernGameHere": "这里有一个小游戏,现在还不能在这里玩", + "tavernCanvasReplied": "{name}回复了", + "@tavernCanvasReplied": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "tavernRegexEdit": "编辑规则", "tavernRegexFieldName": "名字", "tavernRegexFieldFindHelp": "SillyTavern 的写法:/规则/标志,比如 /(早安|晚安)/gi", diff --git a/app/lib/l10n/gen/app_localizations.dart b/app/lib/l10n/gen/app_localizations.dart index 0087403410..afb94fc312 100644 --- a/app/lib/l10n/gen/app_localizations.dart +++ b/app/lib/l10n/gen/app_localizations.dart @@ -38294,6 +38294,12 @@ abstract class L10n { /// **'这里有一个小游戏,现在还不能在这里玩'** String get tavernGameHere; + /// No description provided for @tavernCanvasReplied. + /// + /// In zh, this message translates to: + /// **'{name}回复了'** + String tavernCanvasReplied(String name); + /// No description provided for @tavernRegexEdit. /// /// In zh, this message translates to: diff --git a/app/lib/l10n/gen/app_localizations_en.dart b/app/lib/l10n/gen/app_localizations_en.dart index 3938689ed6..ee6d99fb8d 100644 --- a/app/lib/l10n/gen/app_localizations_en.dart +++ b/app/lib/l10n/gen/app_localizations_en.dart @@ -22126,6 +22126,11 @@ class L10nEn extends L10n { @override String get tavernGameHere => 'A mini-game goes here; it can\'t be played here yet'; + @override + String tavernCanvasReplied(String name) { + return '$name replied'; + } + @override String get tavernRegexEdit => 'Edit rule'; diff --git a/app/lib/l10n/gen/app_localizations_zh.dart b/app/lib/l10n/gen/app_localizations_zh.dart index b503d2d319..ffdc99827c 100644 --- a/app/lib/l10n/gen/app_localizations_zh.dart +++ b/app/lib/l10n/gen/app_localizations_zh.dart @@ -20966,6 +20966,11 @@ class L10nZh extends L10n { @override String get tavernGameHere => '这里有一个小游戏,现在还不能在这里玩'; + @override + String tavernCanvasReplied(String name) { + return '$name回复了'; + } + @override String get tavernRegexEdit => '编辑规则'; diff --git a/app/lib/l10n/parts/tavern_en.arb b/app/lib/l10n/parts/tavern_en.arb index 15d679268c..e9292798ea 100644 --- a/app/lib/l10n/parts/tavern_en.arb +++ b/app/lib/l10n/parts/tavern_en.arb @@ -1014,6 +1014,8 @@ "tavernRollSuccess": "Success", "tavernRollFailure": "Failure", "tavernGameHere": "A mini-game goes here; it can't be played here yet", + "tavernCanvasReplied": "{name} replied", + "@tavernCanvasReplied": {"placeholders": {"name": {"type": "String"}}}, "tavernRegexEdit": "Edit rule", "tavernRegexFieldName": "Name", "tavernRegexFieldFindHelp": "SillyTavern's form: /pattern/flags, e.g. /(hi|bye)/gi", diff --git a/app/lib/l10n/parts/tavern_zh.arb b/app/lib/l10n/parts/tavern_zh.arb index 3ec44882ea..e41958aace 100644 --- a/app/lib/l10n/parts/tavern_zh.arb +++ b/app/lib/l10n/parts/tavern_zh.arb @@ -1014,6 +1014,8 @@ "tavernRollSuccess": "成功", "tavernRollFailure": "失败", "tavernGameHere": "这里有一个小游戏,现在还不能在这里玩", + "tavernCanvasReplied": "{name}回复了", + "@tavernCanvasReplied": {"placeholders": {"name": {"type": "String"}}}, "tavernRegexEdit": "编辑规则", "tavernRegexFieldName": "名字", "tavernRegexFieldFindHelp": "SillyTavern 的写法:/规则/标志,比如 /(早安|晚安)/gi", diff --git a/app/lib/tavern/tavern_canvas.dart b/app/lib/tavern/tavern_canvas.dart index a5d96fee6b..c2ed2431ce 100644 --- a/app/lib/tavern/tavern_canvas.dart +++ b/app/lib/tavern/tavern_canvas.dart @@ -56,10 +56,14 @@ final class TavernCanvasLabels { this.success = 'Success', this.failure = 'Failure', this.game = "A mini-game goes here; it can't be played here yet", + this.replied = '{name} replied', }); final String letter, letterFrom, taken, clue, phone, phoneFrom, roll, rollCheck, success, failure, game; + /// Said to a screen reader when a reply that was being written is in. + final String replied; + Map toJson() => { 'letter': letter, 'letterFrom': letterFrom, @@ -72,6 +76,7 @@ final class TavernCanvasLabels { 'success': success, 'failure': failure, 'game': game, + 'replied': replied, }; } @@ -273,11 +278,12 @@ body{color:var(--dsh-text);font:var(--mainFontSize)/$lineHeight system-ui,-apple .mes_text a{color:var(--dsh-accent);} .mes_text img,.mes_text video,.mes_text svg{max-width:100%;height:auto;} .mes_text pre{white-space:pre-wrap;} -.mes[data-streaming] .mes_text>:last-child::after,.mes[data-streaming] .mes_text:empty::after{content:"\\25CF";color:var(--dsh-muted);margin-left:3px;font-size:.6em;vertical-align:middle;} +.mes[data-streaming] .mes_text>:last-child::after,.mes[data-streaming] .mes_text:empty::after{content:"\\25CF" / "";color:var(--dsh-muted);margin-left:3px;font-size:.6em;vertical-align:middle;} +#dsh-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;} ${themeCss.trim().isEmpty ? '' : ''} -
+
$preload '''; diff --git a/app/lib/ui/tavern/tavern_canvas_view.dart b/app/lib/ui/tavern/tavern_canvas_view.dart index 91bd32038d..5644aa1845 100644 --- a/app/lib/ui/tavern/tavern_canvas_view.dart +++ b/app/lib/ui/tavern/tavern_canvas_view.dart @@ -170,6 +170,7 @@ class _TavernCanvasViewState extends ConsumerState { success: context.l10n.tavernRollSuccess, failure: context.l10n.tavernRollFailure, game: context.l10n.tavernGameHere, + replied: context.l10n.tavernCanvasReplied('{name}'), ), noScripts: noScripts, offline: offline, diff --git a/app/test/tavern/tavern_canvas_test.dart b/app/test/tavern/tavern_canvas_test.dart index d5b0d20a90..1d948bd893 100644 --- a/app/test/tavern/tavern_canvas_test.dart +++ b/app/test/tavern/tavern_canvas_test.dart @@ -195,6 +195,19 @@ void main() { } expect(open, contains('"letterFrom":"{from}的信"')); expect(open, contains('"taken":"获得"')); + // To a screen reader (roleplay-ui §12; the tree itself was read in + // Edge): each message an article named by who speaks; a component a + // group named by its head, the head not said twice; the reply being + // written busy, and only its arrival said, in a live region. + expect(open, contains("m.setAttribute('role','article');")); + expect(open, contains("m.setAttribute('aria-label',nameText.textContent);")); + expect(open, contains('
')); + expect(open, contains("stream.setAttribute('aria-busy','true')")); + expect(open, contains("say(fill(L.replied,'name',last.getAttribute('aria-label')||''))")); + expect(open, contains('"replied":"{name} replied"')); + expect(open, contains("if(!this.hasAttribute('role'))this.setAttribute('role',a[0]);")); + expect('