@musnows/scriverse 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -386,7 +386,18 @@
386
386
  </div>
387
387
  <div class="prompt-composer">
388
388
  <div id="ai-image-attachments" class="ai-image-attachments hidden" aria-label="待发送的图片附件" aria-live="polite"></div>
389
- <button id="ai-attachment-button" class="ai-attachment-button hidden" type="button" aria-label="添加图片附件" title="添加图片附件"><svg class="ai-image-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="4.5" width="17" height="15" rx="2"></rect><circle cx="9" cy="10" r="1.5"></circle><path d="m5.5 17 4.4-4.4 3.3 3.1 2.2-2.2 3.1 3.5"></path></svg></button>
389
+ <section id="ai-scene-panel" class="ai-scene-panel hidden" aria-label="旁白与场景">
390
+ <div class="ai-scene-pin-fields">
391
+ <label for="ai-scene-location">地点<input id="ai-scene-location" maxlength="200" autocomplete="off"></label>
392
+ <label for="ai-scene-present">在场人物<input id="ai-scene-present" maxlength="500" autocomplete="off"></label>
393
+ <label for="ai-scene-time">故事时间<input id="ai-scene-time" maxlength="200" autocomplete="off"></label>
394
+ </div>
395
+ <label for="ai-scene-direction">旁白 / 场景<textarea id="ai-scene-direction" rows="3" maxlength="20000" placeholder="写下本轮旁白或场景推进,会随下一条台词一起发送"></textarea></label>
396
+ </section>
397
+ <div class="prompt-composer-leading">
398
+ <button id="ai-attachment-button" class="ai-attachment-button hidden" type="button" aria-label="添加图片附件" title="添加图片附件"><svg class="ai-image-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="4.5" width="17" height="15" rx="2"></rect><circle cx="9" cy="10" r="1.5"></circle><path d="m5.5 17 4.4-4.4 3.3 3.1 2.2-2.2 3.1 3.5"></path></svg></button>
399
+ <button id="ai-scene-button" class="ai-scene-button hidden" type="button" aria-label="旁白与场景" title="旁白与场景" aria-expanded="false" aria-controls="ai-scene-panel"><svg class="ai-scene-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="4.5" y="6.5" width="15" height="13" rx="1.5"></rect><path d="M8 10.5h8M8 14h5.5"></path></svg></button>
400
+ </div>
390
401
  <input id="ai-attachment-input" class="ai-attachment-input" type="file" accept="image/png,image/jpeg,.jpg,.jpeg" multiple aria-label="选择图片附件">
391
402
  <div id="ai-prompt" class="ai-prompt" contenteditable="true" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-controls="ai-mention-menu" aria-haspopup="listbox" aria-expanded="false" aria-keyshortcuts="Enter" title="Enter 发送,Shift+Enter 换行" data-placeholder="告诉 AI 你想讨论或修改什么……"></div>
392
403
  <div id="ai-mention-menu" class="ai-mention-menu hidden" role="listbox" aria-label="引用角色、设定、章节或上下文能力"></div>
@@ -556,7 +567,7 @@
556
567
  <form id="dynamic-form" method="dialog">
557
568
  <div class="dialog-header">
558
569
  <div><span id="dialog-eyebrow" class="eyebrow">新增</span><h2 id="dialog-title">创建记录</h2><input id="dialog-title-input" class="dialog-title-input hidden" type="text" autocomplete="off" aria-label="记录标题"><p id="dialog-meta" class="dialog-header-meta hidden"></p></div>
559
- <button class="dialog-close" value="cancel" aria-label="关闭" type="submit">×</button>
570
+ <div class="settings-dialog-header-actions"><div id="dialog-context-actions" class="dialog-context-actions"></div><button class="dialog-close" value="cancel" aria-label="关闭" type="submit">×</button></div>
560
571
  </div>
561
572
  <div id="dialog-fields" class="dialog-fields"></div>
562
573
  <div id="dialog-submit-status" class="dialog-submit-status hidden" role="status" aria-live="polite">
@@ -647,6 +658,7 @@
647
658
  </div>
648
659
  </div>
649
660
  <div class="entity-editor-mode-actions">
661
+ <button id="setting-editor-favorite" class="entity-detail-favorite-button hidden" type="button" aria-pressed="false"></button>
650
662
  <button id="setting-editor-edit" class="ghost-button hidden" type="button">编辑设定</button>
651
663
  <button id="setting-editor-confirm" class="ghost-button hidden" type="button">确认候选</button>
652
664
  <button id="setting-editor-deprecate" class="ghost-button hidden" type="button">弃用</button>
@@ -673,6 +685,7 @@
673
685
  <div class="character-editor-title-row"><h1 id="character-editor-title">新建角色</h1><span id="character-editor-version" class="character-version-badge">新档案</span><span id="character-editor-readonly-badge" class="entity-editor-readonly-badge hidden">只读模式</span></div>
674
686
  </div>
675
687
  <div class="character-editor-header-actions">
688
+ <button id="character-editor-favorite" class="entity-detail-favorite-button hidden" type="button" aria-pressed="false"></button>
676
689
  <button id="character-editor-edit" class="primary-button hidden" type="button">编辑角色</button>
677
690
  <button id="character-history-button" class="ghost-button" type="button" aria-controls="character-history-panel" aria-expanded="false">版本历史</button>
678
691
  <button id="character-merge-button" class="ghost-button hidden" type="button">合并角色</button>
@@ -682,7 +695,7 @@
682
695
  <div class="character-editor-workspace">
683
696
  <nav class="character-editor-nav" role="tablist" aria-label="人物档案编辑分区">
684
697
  <button type="button" role="tab" data-character-editor-tab="basic" aria-selected="true">基础资料<small>名称、种族与归属</small></button>
685
- <button type="button" role="tab" data-character-editor-tab="profile" aria-selected="false">人物档案<small>简介、身份与动机</small></button>
698
+ <button type="button" role="tab" data-character-editor-tab="profile" aria-selected="false">人物档案<small>简介、人设摘要、身份与动机</small></button>
686
699
  <button type="button" role="tab" data-character-editor-tab="settings" aria-selected="false">扩展设定<small>属性与长篇章节</small></button>
687
700
  <button type="button" role="tab" data-character-editor-tab="state" aria-selected="false">状态与约束<small>当前状态与锁定项</small></button>
688
701
  <button type="button" role="tab" data-character-editor-tab="relationships" aria-selected="false">人物关系<small>关联人物与关系关键词</small></button>
@@ -708,6 +721,7 @@
708
721
  </div>
709
722
  <div class="character-editor-header-actions">
710
723
  <span id="knowledge-editor-header-note" class="knowledge-editor-header-note">独立资料页</span>
724
+ <button id="knowledge-editor-favorite" class="entity-detail-favorite-button hidden" type="button" aria-pressed="false"></button>
711
725
  <button id="knowledge-editor-edit" class="primary-button hidden" type="button">编辑档案</button>
712
726
  <button id="knowledge-editor-history" class="ghost-button hidden" type="button">版本历史</button>
713
727
  <button id="knowledge-editor-merge" class="ghost-button hidden" type="button">合并档案</button>
@@ -1077,13 +1091,12 @@
1077
1091
  <form id="password-form" class="account-settings-form password-settings-form" method="post" action="/api/auth/password"><label>当前密码<input name="currentPassword" type="password" autocomplete="current-password" maxlength="200" required></label><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="10" maxlength="200" required></label><label>确认新密码<input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="10" maxlength="200" required></label><button class="primary-button" type="submit">更新密码</button></form>
1078
1092
  </section>
1079
1093
  <section class="account-settings-section" aria-labelledby="api-key-settings-title">
1080
- <header class="account-settings-section-header"><h3 id="api-key-settings-title">CLI API Key</h3><p>用于 CLI 登录。服务端只保存摘要,重置后旧 Key 会立即失效,新 Key 仅显示一次。</p></header>
1094
+ <header class="account-settings-section-header"><h3 id="api-key-settings-title">CLI API Key</h3><p>用于 CLI 登录。完整 Key 不会显示在页面上,可随时复制;重置后旧 Key 会立即失效。</p></header>
1081
1095
  <div class="api-key-settings">
1082
1096
  <p id="api-key-status">正在读取 API Key 状态…</p>
1083
- <button id="api-key-reset-button" class="primary-button" type="button">生成 API Key</button>
1084
- <div id="api-key-result" class="api-key-result hidden">
1085
- <label>请立即保存<input id="api-key-value" readonly spellcheck="false"></label>
1086
- <button id="api-key-copy-button" class="ghost-button" type="button"><svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>复制</button>
1097
+ <div class="api-key-actions">
1098
+ <button id="api-key-copy-button" class="ghost-button" type="button" hidden aria-label="复制 API Key"><svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>复制</button>
1099
+ <button id="api-key-reset-button" class="primary-button" type="button">生成 API Key</button>
1087
1100
  </div>
1088
1101
  </div>
1089
1102
  </section>
@@ -1245,6 +1258,6 @@
1245
1258
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1246
1259
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1247
1260
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1248
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v1&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1"></script>
1261
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v1&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2"></script>
1249
1262
  </body>
1250
1263
  </html>
@@ -0,0 +1,100 @@
1
+ const SCENE_DIRECTION_TAG = "scene_direction";
2
+ const USER_MESSAGE_TAG = "user_message";
3
+ const SCENE_PIN_LOCATION_MAX = 200;
4
+ const SCENE_PIN_PRESENT_MAX = 500;
5
+ const SCENE_PIN_TIME_MAX = 200;
6
+
7
+ function escapeXmlText(text) {
8
+ return String(text ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;");
9
+ }
10
+
11
+ function unescapeXmlText(text) {
12
+ return String(text ?? "").replaceAll("&lt;", "<").replaceAll("&amp;", "&");
13
+ }
14
+
15
+ function wrapRegion(tag, body, escape = true) {
16
+ const trimmed = String(body ?? "").trim();
17
+ if (!trimmed) return "";
18
+ const content = escape ? escapeXmlText(trimmed) : trimmed;
19
+ return `<${tag}>\n${content}\n</${tag}>`;
20
+ }
21
+
22
+ function unwrapRegion(content, tag) {
23
+ const open = `<${tag}>`;
24
+ const close = `</${tag}>`;
25
+ if (!content.startsWith(open)) return null;
26
+ const closeIndex = content.indexOf(close, open.length);
27
+ if (closeIndex < 0) return null;
28
+ const inner = content.slice(open.length, closeIndex).replace(/^\n/u, "").replace(/\n$/u, "");
29
+ const rest = content.slice(closeIndex + close.length).trim();
30
+ return { inner, rest };
31
+ }
32
+
33
+ export function emptyRoleplayScenePin() {
34
+ return { location: "", present: "", timeLabel: "" };
35
+ }
36
+
37
+ export function normalizeRoleplayScenePin(value) {
38
+ const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
39
+ const clip = (raw, maximum) => (typeof raw === "string" ? raw.trim().slice(0, maximum) : "");
40
+ return {
41
+ location: clip(source.location, SCENE_PIN_LOCATION_MAX),
42
+ present: clip(source.present, SCENE_PIN_PRESENT_MAX),
43
+ timeLabel: clip(source.timeLabel, SCENE_PIN_TIME_MAX)
44
+ };
45
+ }
46
+
47
+ export function roleplayScenePinHasContent(pin) {
48
+ const normalized = normalizeRoleplayScenePin(pin);
49
+ return Boolean(normalized.location || normalized.present || normalized.timeLabel);
50
+ }
51
+
52
+ export function formatRoleplayScenePinText(pin) {
53
+ const normalized = normalizeRoleplayScenePin(pin);
54
+ const lines = [];
55
+ if (normalized.location) lines.push(`地点:${normalized.location}`);
56
+ if (normalized.present) lines.push(`在场:${normalized.present}`);
57
+ if (normalized.timeLabel) lines.push(`故事时间:${normalized.timeLabel}`);
58
+ return lines.join("\n");
59
+ }
60
+
61
+ export function composeRoleplayCurrentUserTurn(sceneDirection, userMessage) {
62
+ return [
63
+ wrapRegion(SCENE_DIRECTION_TAG, sceneDirection, true),
64
+ wrapRegion(USER_MESSAGE_TAG, userMessage, false)
65
+ ].filter(Boolean).join("\n\n");
66
+ }
67
+
68
+ export function composeRoleplayStoredUserContent(sceneDirection, userMessage) {
69
+ const scene = wrapRegion(SCENE_DIRECTION_TAG, sceneDirection, true);
70
+ if (!scene) return String(userMessage ?? "");
71
+ const user = wrapRegion(USER_MESSAGE_TAG, userMessage, false);
72
+ return [scene, user].filter(Boolean).join("\n\n");
73
+ }
74
+
75
+ export function parseRoleplayUserTurn(content) {
76
+ const original = String(content ?? "");
77
+ const trimmed = original.trim();
78
+ const scene = unwrapRegion(trimmed, SCENE_DIRECTION_TAG);
79
+ if (!scene) {
80
+ return { sceneDirection: "", userMessage: original, hasMarkup: false };
81
+ }
82
+ const user = unwrapRegion(scene.rest, USER_MESSAGE_TAG);
83
+ return {
84
+ sceneDirection: unescapeXmlText(scene.inner),
85
+ userMessage: user ? user.inner : scene.rest,
86
+ hasMarkup: true
87
+ };
88
+ }
89
+
90
+ export function roleplayUserTurnDisplayText(content) {
91
+ const parsed = parseRoleplayUserTurn(content);
92
+ if (!parsed.hasMarkup) return content;
93
+ return [parsed.sceneDirection, parsed.userMessage].filter(Boolean).join("\n");
94
+ }
95
+
96
+ export function roleplayUserTurnTitleSource(content) {
97
+ const parsed = parseRoleplayUserTurn(content);
98
+ if (!parsed.hasMarkup) return content;
99
+ return parsed.userMessage || parsed.sceneDirection;
100
+ }
@@ -744,7 +744,7 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
744
744
  .toast.token-usage-details-toast {
745
745
  display: grid;
746
746
  grid-template-rows: auto minmax(0, 1fr);
747
- width: min(720px, calc(100vw - 48px));
747
+ width: min(1200px, calc(100vw - 48px));
748
748
  max-width: none;
749
749
  max-height: min(72dvh, 560px);
750
750
  padding: 0;
@@ -762,7 +762,7 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
762
762
  .token-usage-details-body { display: grid; gap: 10px; min-height: 0; padding: 12px 14px 14px; overflow-y: auto; color: var(--muted); font-size: 11px; line-height: 1.6; overscroll-behavior: contain; }
763
763
  .token-usage-details-note { margin: 0; }
764
764
  .token-usage-details-table-scroll { min-width: 0; overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
765
- .token-usage-details-table { width: 100%; min-width: 690px; border-collapse: collapse; color: var(--ink); font: 10px/1.45 var(--font-latin), var(--font-cjk), monospace; }
765
+ .token-usage-details-table { width: 100%; min-width: 820px; border-collapse: collapse; color: var(--ink); font: 10px/1.45 var(--font-latin), var(--font-cjk), monospace; }
766
766
  .token-usage-details-table caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
767
767
  .token-usage-details-table th, .token-usage-details-table td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: right; white-space: nowrap; }
768
768
  .token-usage-details-table thead th { background: var(--surface-soft); color: var(--muted); font-size: 9px; font-weight: 600; letter-spacing: .03em; }
@@ -1240,11 +1240,8 @@ body.work-viewer-mode .character-editor-actions { display: none !important; }
1240
1240
  .password-settings-form { grid-template-columns: repeat(3, minmax(0, 1fr)) auto; }
1241
1241
  .api-key-settings { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-top: 16px; }
1242
1242
  .api-key-settings > p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
1243
- .api-key-result { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; }
1244
- .api-key-result.hidden { display: none; }
1245
- .api-key-result label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
1246
- .api-key-result input { width: 100%; min-height: 38px; padding: 9px 10px; background: var(--surface); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 11px; }
1247
- .api-key-result button { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; }
1243
+ .api-key-actions { display: inline-flex; align-items: center; gap: 8px; }
1244
+ .api-key-actions button { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; }
1248
1245
  .book-access-badge { display: inline-flex; width: fit-content; margin-top: 7px; padding: 2px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--accent-dark); font-size: 9px; }
1249
1246
  .book-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(205px, 1fr)); gap: clamp(22px, 3vw, 38px); width: 100%; min-width: 0; max-width: 1400px; margin: 0 auto; }
1250
1247
  .book-card { position: relative; min-width: 0; border: 0; background: transparent; text-align: left; }
@@ -1513,9 +1510,29 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1513
1510
  .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 13px; }
1514
1511
  .record-card { position: relative; border: 1px solid var(--line); background: var(--surface-soft); padding: 18px; min-height: 130px; border-radius: 4px; }
1515
1512
  .record-card.has-card-edit h3 { padding-right: 34px; }
1513
+ .record-card.has-favorite-control h3 { padding-right: 70px; }
1516
1514
  .record-card-edit { position: absolute; top: 12px; right: 12px; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1517
1515
  .record-card-edit:hover, .record-card-edit:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1518
1516
  .record-card-edit svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
1517
+ .character-favorite-button { position: absolute; top: 12px; right: 48px; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1518
+ .character-favorite-button:hover, .character-favorite-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1519
+ .character-favorite-button.is-favorite { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1520
+ .character-favorite-button:disabled { cursor: not-allowed; opacity: .5; }
1521
+ .character-favorite-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1522
+ .character-favorite-button.is-favorite svg { fill: currentColor; }
1523
+ .record-favorite-button { display: inline-grid; flex: 0 0 auto; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1524
+ .record-favorite-button.is-card-control { position: absolute; top: 12px; right: 48px; }
1525
+ .record-favorite-button:hover, .record-favorite-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1526
+ .record-favorite-button.is-favorite { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1527
+ .record-favorite-button:disabled { cursor: not-allowed; opacity: .5; }
1528
+ .record-favorite-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1529
+ .record-favorite-button.is-favorite svg { fill: currentColor; }
1530
+ .entity-detail-favorite-button { display: inline-grid; flex: 0 0 36px; place-items: center; width: 36px; min-width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1531
+ .entity-detail-favorite-button:hover, .entity-detail-favorite-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1532
+ .entity-detail-favorite-button.is-favorite { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1533
+ .entity-detail-favorite-button:disabled { cursor: not-allowed; opacity: .5; }
1534
+ .entity-detail-favorite-button svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1535
+ .entity-detail-favorite-button.is-favorite svg { fill: currentColor; }
1519
1536
  .record-card h3 { font-size: 16px; margin: 5px 0 10px; font-weight: 600; }
1520
1537
  .record-card p { color: var(--muted); font-size: 12px; line-height: 1.55; margin: 0; white-space: pre-wrap; }
1521
1538
  .record-card small { color: var(--accent); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
@@ -1551,8 +1568,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1551
1568
  .character-card-heading { display: flex; min-width: 0; align-items: center; gap: 7px; }
1552
1569
  .character-card-heading .character-avatar { width: 32px; height: 32px; font-size: 12px; }
1553
1570
  .character-card-heading h3 { min-width: 0; }
1554
- .record-card.has-card-edit .character-card-heading { padding-right: 34px; }
1571
+ .record-card.has-card-edit .character-card-heading { padding-right: 70px; }
1555
1572
  .record-card.has-card-edit .character-card-heading h3 { padding-right: 0; }
1573
+ .module-row .character-favorite-button { position: static; }
1574
+ .module-row .record-favorite-button { position: static; }
1556
1575
  .character-lock-badge { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 3px; min-height: 20px; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent-dark); font-size: 9px; font-variant-numeric: tabular-nums; line-height: 1; }
1557
1576
  .entity-lifecycle-badge { display: inline-flex; align-items: center; min-height: 20px; margin-left: 7px; padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--accent) 58%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 13%, var(--surface)); color: var(--accent-dark); font-size: 9px; font-weight: 650; line-height: 1; vertical-align: middle; white-space: nowrap; }
1558
1577
  .character-lock-badge svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
@@ -1597,6 +1616,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1597
1616
  .card-actions { display: flex; gap: 6px; margin-top: 15px; }
1598
1617
  .card-actions button { border: 1px solid var(--line); background: transparent; border-radius: 3px; font-size: 10px; padding: 5px 8px; }
1599
1618
  .card-actions .record-card-edit { position: static; padding: 0; }
1619
+ .card-actions .record-favorite-button { position: static; padding: 0; }
1600
1620
  .danger-button { border: 1px solid color-mix(in srgb, var(--accent) 52%, var(--line)); border-radius: 4px; background: transparent; color: var(--accent-dark); padding: 8px 13px; font-size: 12px; }
1601
1621
  .danger-button:hover, .danger-button:focus-visible { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); outline: none; }
1602
1622
  .card-actions .danger-button { border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); color: var(--accent-dark); }
@@ -2813,6 +2833,16 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2813
2833
  .prompt-options .ai-roleplay-user-character { grid-column: 1 / -1; }
2814
2834
  .ai-panel.is-roleplaying .ai-roleplay-character { border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); }
2815
2835
  .prompt-composer { position: relative; }
2836
+ .prompt-composer-leading { position: absolute; bottom: 8px; left: 8px; z-index: 2; display: flex; gap: 6px; align-items: center; }
2837
+ .ai-scene-panel { display: grid; gap: 7px; margin-bottom: 6px; padding: 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--paper); }
2838
+ .ai-scene-panel.hidden { display: none; }
2839
+ .ai-scene-pin-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
2840
+ @media (max-width: 640px) {
2841
+ .ai-scene-pin-fields { grid-template-columns: minmax(0, 1fr); }
2842
+ }
2843
+ .ai-scene-panel label { display: grid; gap: 3px; color: var(--muted); font-size: 9px; }
2844
+ .ai-scene-panel input, .ai-scene-panel textarea { width: 100%; padding: 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 10px; }
2845
+ .ai-scene-panel textarea { min-height: 64px; resize: vertical; }
2816
2846
  .ai-image-attachments { display: flex; gap: 5px; max-height: 38px; margin-bottom: 6px; overflow-x: auto; overflow-y: hidden; padding: 5px 0 0; }
2817
2847
  .ai-image-attachment { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; height: 28px; margin: 0; padding: 2px 3px 2px 4px; border: 1px solid color-mix(in srgb, var(--accent) 48%, var(--line)); border-radius: 5px; background: var(--paper); }
2818
2848
  .ai-image-attachment-preview { display: inline-flex; align-items: center; gap: 4px; width: auto; height: 22px; min-width: 0; padding: 0 3px 0 0; border: 0; background: transparent; color: var(--ink); font: 9px/1 var(--font-cjk), sans-serif; white-space: nowrap; cursor: zoom-in; }
@@ -2828,10 +2858,11 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2828
2858
  .ai-image-preview-body { display: grid; place-items: center; min-height: 180px; max-height: calc(88vh - 106px); padding: 16px 20px 20px; overflow: auto; background: var(--paper); }
2829
2859
  .ai-image-preview-body img { display: block; max-width: 100%; max-height: calc(88vh - 150px); width: auto; height: auto; object-fit: contain; border-radius: 3px; }
2830
2860
  .ai-attachment-input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
2831
- .ai-attachment-button { position: absolute; bottom: 8px; left: 8px; z-index: 2; display: grid; place-items: center; width: 32px; min-width: 32px; min-height: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--muted); }
2832
- .ai-attachment-button:hover, .ai-attachment-button:focus-visible { border-color: color-mix(in srgb, var(--accent) 56%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); outline: 0; }
2833
- .ai-attachment-button:disabled { cursor: wait; opacity: .56; }
2834
- .ai-image-button-icon { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
2861
+ .ai-attachment-button, .ai-scene-button { display: grid; place-items: center; width: 32px; min-width: 32px; min-height: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--muted); }
2862
+ .ai-attachment-button:hover, .ai-attachment-button:focus-visible, .ai-scene-button:hover, .ai-scene-button:focus-visible, .ai-scene-button[aria-expanded="true"], .ai-scene-button.is-active { border-color: color-mix(in srgb, var(--accent) 56%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); outline: 0; }
2863
+ .ai-attachment-button:disabled, .ai-scene-button:disabled { cursor: wait; opacity: .56; }
2864
+ .ai-scene-button.hidden { display: none; }
2865
+ .ai-image-button-icon, .ai-scene-button-icon { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
2835
2866
  .ai-mention-menu { position: absolute; z-index: 20; left: 0; right: 0; bottom: calc(100% + 6px); max-height: 230px; overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 12px 34px rgba(49,42,32,.18); }
2836
2867
  .ai-mention-option { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 8px; width: 100%; padding: 7px 8px; border: 0; border-radius: 4px; background: transparent; text-align: left; }
2837
2868
  .ai-mention-option:hover, .ai-mention-option:focus-visible, .ai-mention-option.is-active { background: var(--paper-deep); }
@@ -2914,6 +2945,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2914
2945
  .ai-citation-card.is-expanded pre { display: block; }
2915
2946
  .message-citations { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }.message-citations span { padding: 3px 6px; border: 1px solid currentColor; border-radius: 10px; font-size: 8px; opacity: .75; }
2916
2947
  .user-message-mentions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; margin-top: 8px; padding-top: 7px; border-top: 1px solid color-mix(in srgb, currentColor 28%, transparent); font-size: 8px; line-height: 1.4; }
2948
+ .user-message-scene { margin: 0 0 8px; padding: 0 0 7px; border-bottom: 1px solid color-mix(in srgb, currentColor 28%, transparent); font-size: 10px; line-height: 1.5; }
2949
+ .user-message-scene-label { display: block; margin-bottom: 3px; opacity: .72; letter-spacing: .08em; font-size: 8px; }
2950
+ .user-message-scene-body { white-space: pre-wrap; overflow-wrap: anywhere; }
2951
+ .user-message .message-body:empty { display: none; }
2917
2952
  .user-message-mentions-label { flex: 0 0 auto; opacity: .72; letter-spacing: .08em; }
2918
2953
  .user-message-mention { min-width: 0; max-width: 100%; padding: 2px 6px; overflow-wrap: anywhere; border: 1px solid color-mix(in srgb, currentColor 55%, transparent); border-radius: 999px; background: color-mix(in srgb, currentColor 12%, transparent); }
2919
2954
 
@@ -2948,7 +2983,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2948
2983
  right: auto;
2949
2984
  bottom: auto;
2950
2985
  left: 50%;
2951
- width: min(720px, calc(100vw - 32px));
2986
+ width: min(1200px, calc(100vw - 32px));
2952
2987
  max-width: calc(100vw - 32px);
2953
2988
  transform: translate(-50%, -50%);
2954
2989
  }
@@ -3075,6 +3110,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3075
3110
  .dialog::backdrop { background: rgba(34,30,25,.42); backdrop-filter: blur(3px); }
3076
3111
  .dialog-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 22px 24px 16px; border-bottom: 1px solid var(--line); }
3077
3112
  .dialog-header h2 { margin: 0; font-size: 23px; font-weight: 500; }
3113
+ #form-dialog .dialog-header { gap: 12px; }
3114
+ #form-dialog .dialog-header > div:first-child { flex: 1 1 auto; min-width: 0; }
3115
+ #form-dialog .settings-dialog-header-actions { flex: 0 0 auto; }
3116
+ #form-dialog .dialog-title-input { width: min(560px, 100%); }
3117
+ .dialog-context-actions { display: flex; align-items: center; gap: 8px; }
3118
+ .dialog-context-actions:empty { display: none; }
3119
+ .dialog-context-actions .ghost-button { min-height: 36px; white-space: nowrap; }
3078
3120
  .dialog-title-input { display: block; width: min(560px, calc(100vw - 150px)); min-width: 0; margin: -5px 0 0; padding: 0 0 4px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--ink); font-size: 23px; font-weight: 500; line-height: 1.3; }
3079
3121
  .dialog-title-input:focus { border-bottom-color: var(--accent); outline: none; }
3080
3122
  .dialog-title-input::placeholder { color: var(--muted); opacity: .8; }
@@ -3521,7 +3563,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3521
3563
  .character-editor-section > header p { max-width: 430px; margin: 0; color: var(--muted); font-size: 10px; line-height: 1.55; text-align: right; }
3522
3564
  .character-editor-section-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px 20px; }
3523
3565
  .character-editor-section-fields > label, .character-editor-section-fields > .form-field { display: grid; align-content: start; gap: 7px; color: var(--muted); font-size: 11px; }
3524
- .character-editor-section-fields > label:has(textarea), .character-editor-section-fields > .structured-list-field, .character-editor-section-fields > .chip-field, .character-editor-section-fields > .markdown-editor-field, .character-editor-field-help { grid-column: 1 / -1; }
3566
+ .character-editor-section-fields > label:has(textarea), .character-editor-section-fields > .form-field:has(textarea), .character-editor-section-fields > .structured-list-field, .character-editor-section-fields > .chip-field, .character-editor-section-fields > .markdown-editor-field, .character-editor-field-help { grid-column: 1 / -1; }
3525
3567
  .character-editor-section-fields input, .character-editor-section-fields textarea, .character-editor-section-fields select { width: 100%; padding: 10px 11px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); font-size: 13px; }
3526
3568
  .character-editor-section-fields textarea { min-height: 120px; resize: vertical; line-height: 1.65; }
3527
3569
  .character-editor-section-fields .section-list-row textarea { min-height: 180px; }
@@ -3802,7 +3844,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3802
3844
  .ai-history-dialog-body { min-height: min(360px, 62vh); padding: 12px 14px 18px; }
3803
3845
  .account-settings-body { padding: 16px; }
3804
3846
  .account-settings-section { padding: 15px; }
3805
- .account-settings-form, .password-settings-form, .api-key-settings, .api-key-result { grid-template-columns: minmax(0, 1fr); }
3847
+ .account-settings-form, .password-settings-form, .api-key-settings { grid-template-columns: minmax(0, 1fr); }
3848
+ .api-key-actions { width: 100%; }
3849
+ .api-key-actions button { flex: 1; }
3806
3850
  .avatar-settings { grid-template-columns: 64px minmax(0, 1fr); }
3807
3851
  .profile-avatar-preview { width: 64px; height: 64px; }
3808
3852
  .character-avatar-editor-preview { width: 64px; height: 64px; }
@@ -3813,6 +3857,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3813
3857
  .account-settings-form .primary-button { width: 100%; }
3814
3858
  .usage-stat-grid { grid-template-columns: minmax(0, 1fr); }
3815
3859
  .usage-stat { min-height: 104px; }
3860
+ .ai-scene-pin-fields { grid-template-columns: minmax(0, 1fr); }
3816
3861
  }
3817
3862
 
3818
3863
  /* 移动端工作台:仅在窄屏覆盖桌面三栏布局,保留桌面端原有尺寸与排列。 */
@@ -4099,6 +4144,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
4099
4144
  .entity-editor-header, .character-editor-header { grid-template-columns: auto minmax(0, 1fr); gap: 8px; padding: 12px var(--mobile-gutter); }
4100
4145
  .entity-editor-mode-actions, .character-editor-header-actions { grid-column: 1 / -1; justify-content: stretch; flex-wrap: wrap; }
4101
4146
  .entity-editor-mode-actions > button, .character-editor-header-actions > button { flex: 1 1 auto; }
4147
+ .entity-editor-mode-actions > .entity-detail-favorite-button, .character-editor-header-actions > .entity-detail-favorite-button { flex: 0 0 40px; width: 40px; min-width: 40px; height: 40px; }
4148
+ .dialog-context-actions .entity-detail-favorite-button { flex-basis: 40px; width: 40px; min-width: 40px; height: 40px; }
4102
4149
  .setting-editor-header { min-height: 0; }
4103
4150
  .setting-editor-content { padding: 12px; }
4104
4151
  .setting-markdown-field { height: 68dvh; }
@@ -4176,6 +4223,10 @@ body { font-size: var(--ui-font-size); }
4176
4223
  .prompt-options select, .ai-model-popover > label, .ai-model-option-copy strong, .ai-model-options-empty, .ai-mention-option strong, .ai-mention-empty { font-size: calc(10px * var(--ai-font-scale)); }
4177
4224
  .ai-mention-option small { font-size: calc(8px * var(--ai-font-scale)); }
4178
4225
  .ai-prompt { font-size: var(--ai-font-size); }
4226
+ .ai-scene-panel label { font-size: calc(9px * var(--ai-font-scale)); }
4227
+ .ai-scene-panel input, .ai-scene-panel textarea { font-size: calc(10px * var(--ai-font-scale)); }
4228
+ .user-message-scene { font-size: calc(10px * var(--ai-font-scale)); }
4229
+ .user-message-scene-label { font-size: calc(8px * var(--ai-font-scale)); }
4179
4230
  .ai-prompt-reference { font-size: calc(10px * var(--ai-font-scale)); }
4180
4231
  .ai-context-meter b { font-size: calc(9px * var(--ai-font-scale)); }
4181
4232
  .ai-context-popover-header strong { font-size: calc(12px * var(--ai-font-scale)); }
@@ -0,0 +1,4 @@
1
+ export function resolveToastRegionHost<TDialog extends { open?: boolean }, TFallback>(
2
+ dialogs: ReadonlyArray<TDialog | null | undefined> | null | undefined,
3
+ fallbackHost?: TFallback | null
4
+ ): TDialog | TFallback | null;
@@ -0,0 +1,5 @@
1
+ /** 打开的模态对话框会让文档其余部分 inert;Toast 必须挂到最顶层对话框内才能接收点击。 */
2
+ export function resolveToastRegionHost(dialogs, fallbackHost) {
3
+ const openDialogs = (Array.isArray(dialogs) ? dialogs : []).filter((dialog) => Boolean(dialog?.open));
4
+ return openDialogs.at(-1) ?? fallbackHost ?? null;
5
+ }
@@ -0,0 +1,98 @@
1
+ const SCENE_DIRECTION_TAG = "scene_direction";
2
+ const USER_MESSAGE_TAG = "user_message";
3
+ const SCENE_PIN_LOCATION_MAX = 200;
4
+ const SCENE_PIN_PRESENT_MAX = 500;
5
+ const SCENE_PIN_TIME_MAX = 200;
6
+ function escapeXmlText(text) {
7
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;");
8
+ }
9
+ function unescapeXmlText(text) {
10
+ return text.replaceAll("&lt;", "<").replaceAll("&amp;", "&");
11
+ }
12
+ function wrapRegion(tag, body, escape = true) {
13
+ const trimmed = body.trim();
14
+ if (!trimmed)
15
+ return "";
16
+ const content = escape ? escapeXmlText(trimmed) : trimmed;
17
+ return `<${tag}>\n${content}\n</${tag}>`;
18
+ }
19
+ function unwrapRegion(content, tag) {
20
+ const open = `<${tag}>`;
21
+ const close = `</${tag}>`;
22
+ if (!content.startsWith(open))
23
+ return null;
24
+ const closeIndex = content.indexOf(close, open.length);
25
+ if (closeIndex < 0)
26
+ return null;
27
+ const inner = content.slice(open.length, closeIndex).replace(/^\n/u, "").replace(/\n$/u, "");
28
+ const rest = content.slice(closeIndex + close.length).trim();
29
+ return { inner, rest };
30
+ }
31
+ export function emptyRoleplayScenePin() {
32
+ return { location: "", present: "", timeLabel: "" };
33
+ }
34
+ export function normalizeRoleplayScenePin(value) {
35
+ const source = value && typeof value === "object" && !Array.isArray(value)
36
+ ? value
37
+ : {};
38
+ const clip = (raw, maximum) => (typeof raw === "string" ? raw.trim().slice(0, maximum) : "");
39
+ return {
40
+ location: clip(source.location, SCENE_PIN_LOCATION_MAX),
41
+ present: clip(source.present, SCENE_PIN_PRESENT_MAX),
42
+ timeLabel: clip(source.timeLabel, SCENE_PIN_TIME_MAX)
43
+ };
44
+ }
45
+ export function roleplayScenePinHasContent(pin) {
46
+ return Boolean(pin.location || pin.present || pin.timeLabel);
47
+ }
48
+ export function formatRoleplayScenePinText(pin) {
49
+ const normalized = normalizeRoleplayScenePin(pin);
50
+ const lines = [];
51
+ if (normalized.location)
52
+ lines.push(`地点:${normalized.location}`);
53
+ if (normalized.present)
54
+ lines.push(`在场:${normalized.present}`);
55
+ if (normalized.timeLabel)
56
+ lines.push(`故事时间:${normalized.timeLabel}`);
57
+ return lines.join("\n");
58
+ }
59
+ export function composeRoleplayCurrentUserTurn(sceneDirection, userMessage) {
60
+ return [
61
+ wrapRegion(SCENE_DIRECTION_TAG, sceneDirection, true),
62
+ wrapRegion(USER_MESSAGE_TAG, userMessage, false)
63
+ ].filter(Boolean).join("\n\n");
64
+ }
65
+ export function composeRoleplayStoredUserContent(sceneDirection, userMessage) {
66
+ const scene = wrapRegion(SCENE_DIRECTION_TAG, sceneDirection, true);
67
+ if (!scene)
68
+ return userMessage;
69
+ const user = wrapRegion(USER_MESSAGE_TAG, userMessage, false);
70
+ return [scene, user].filter(Boolean).join("\n\n");
71
+ }
72
+ export function parseRoleplayUserTurn(content) {
73
+ const original = String(content ?? "");
74
+ const trimmed = original.trim();
75
+ const scene = unwrapRegion(trimmed, SCENE_DIRECTION_TAG);
76
+ if (!scene) {
77
+ return { sceneDirection: "", userMessage: original, hasMarkup: false };
78
+ }
79
+ const user = unwrapRegion(scene.rest, USER_MESSAGE_TAG);
80
+ return {
81
+ sceneDirection: unescapeXmlText(scene.inner),
82
+ userMessage: user ? user.inner : scene.rest,
83
+ hasMarkup: true
84
+ };
85
+ }
86
+ export function roleplayUserTurnDisplayText(content) {
87
+ const parsed = parseRoleplayUserTurn(content);
88
+ if (!parsed.hasMarkup)
89
+ return content;
90
+ return [parsed.sceneDirection, parsed.userMessage].filter(Boolean).join("\n");
91
+ }
92
+ export function roleplayUserTurnTitleSource(content) {
93
+ const parsed = parseRoleplayUserTurn(content);
94
+ if (!parsed.hasMarkup)
95
+ return content;
96
+ return parsed.userMessage || parsed.sceneDirection;
97
+ }
98
+ //# sourceMappingURL=roleplay-turn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"roleplay-turn.js","sourceRoot":"","sources":["../src/roleplay-turn.ts"],"names":[],"mappings":"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"}
package/dist/security.js CHANGED
@@ -6,6 +6,7 @@ import { Agent } from "undici";
6
6
  import { AppError } from "./errors.js";
7
7
  import { logger } from "./logger.js";
8
8
  import { parseBooleanEnvironmentValue } from "./utils.js";
9
+ export const PRIVATE_AI_ENDPOINTS_ENV = "APP_ALLOW_PRIVATE_AI_ENDPOINTS";
9
10
  const maximumRateEntries = 10_000;
10
11
  /**
11
12
  * Express 默认路由大小写不敏感,但 request.path 保留原始大小写。
@@ -376,18 +377,31 @@ function rememberPinnedAiAddresses(hostname, addresses) {
376
377
  pinnedAiAddresses.delete(key);
377
378
  pinnedAiAddresses.set(key, addresses);
378
379
  }
380
+ async function lookupEndpointAddresses(hostname) {
381
+ if (isIP(hostname))
382
+ return [{ address: hostname, family: isIP(hostname) }];
383
+ return (await lookup(hostname, { all: true, verbatim: true }).catch(() => [])).map(({ address, family }) => ({
384
+ address,
385
+ family: family
386
+ }));
387
+ }
388
+ export async function aiEndpointUsesPrivateNetwork(value) {
389
+ try {
390
+ const hostname = normalizedUrlHostname(new URL(value));
391
+ const addresses = await lookupEndpointAddresses(hostname);
392
+ return addresses.some(({ address }) => unsafeIpKind(address) === "private");
393
+ }
394
+ catch {
395
+ return false;
396
+ }
397
+ }
379
398
  export async function assertSafeAiEndpoint(value, allowPrivateNetwork = false) {
380
399
  const endpoint = new URL(value);
381
400
  if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) {
382
401
  throw new AppError(400, "UNSAFE_PROVIDER_ENDPOINT", "AI 供应商地址必须是无内嵌凭据的 HTTP 或 HTTPS 地址");
383
402
  }
384
403
  const hostname = normalizedUrlHostname(endpoint);
385
- const addresses = isIP(hostname)
386
- ? [{ address: hostname, family: isIP(hostname) }]
387
- : (await lookup(hostname, { all: true, verbatim: true }).catch(() => [])).map(({ address, family }) => ({
388
- address,
389
- family: family
390
- }));
404
+ const addresses = await lookupEndpointAddresses(hostname);
391
405
  if (!addresses.length)
392
406
  throw new AppError(400, "UNSAFE_PROVIDER_ENDPOINT", "AI 供应商域名无法解析");
393
407
  for (const { address } of addresses) {
@@ -474,6 +488,18 @@ export async function fetchSafeAiEndpoint(fetchImpl, url, init, validateOutbound
474
488
  }
475
489
  throw new AppError(502, "PROVIDER_REDIRECT_LIMIT", "AI 供应商重定向次数过多");
476
490
  }
491
+ export function isPrivateAiEndpointsExplicitlyEnabled(environment) {
492
+ return parseBooleanEnvironmentValue(environment[PRIVATE_AI_ENDPOINTS_ENV]) === true;
493
+ }
494
+ /** 仅在环境变量被显式开启时写入启动警告;开发环境默认放行不会触发。 */
495
+ export function warnIfPrivateAiEndpointsEnabled(environment) {
496
+ if (!isPrivateAiEndpointsExplicitlyEnabled(environment))
497
+ return;
498
+ logger.warn("security.private_ai_endpoints.enabled", {
499
+ env: PRIVATE_AI_ENDPOINTS_ENV,
500
+ message: "Private and loopback AI provider endpoints are allowed. This weakens SSRF protection and can expose credentials to local or internal services. Enable it only when you must reach a trusted local model."
501
+ });
502
+ }
477
503
  export function resolveRuntimeSecurity(environment, requireAuthentication = false) {
478
504
  const production = environment.NODE_ENV === "production";
479
505
  const username = environment.APP_AUTH_USERNAME?.trim() ?? "";
@@ -496,7 +522,7 @@ export function resolveRuntimeSecurity(environment, requireAuthentication = fals
496
522
  ...(username ? { auth: { username, password } } : {}),
497
523
  trustProxy,
498
524
  enforceSameOrigin: true,
499
- allowPrivateAiEndpoints: parseBooleanEnvironmentValue(environment.APP_ALLOW_PRIVATE_AI_ENDPOINTS) ?? !production,
525
+ allowPrivateAiEndpoints: parseBooleanEnvironmentValue(environment[PRIVATE_AI_ENDPOINTS_ENV]) ?? !production,
500
526
  allowRegistration,
501
527
  ...(setupToken ? { setupToken } : {})
502
528
  };