Last active
August 2, 2025 02:52
-
-
Save CodeBoy2006/5b5bf6273cede317fce287b2fdcc244f to your computer and use it in GitHub Desktop.
SillyTavern 3D视野插件 - 让角色的视野可视化 (通过酒馆助手加载)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <vision-renderer-guide> | |
| <rule> | |
| **启用附属模块[视觉投影仪] - 强制执行** | |
| 视觉投影仪 (Scene Projector) 被激活为一个强制性子模块,用于处理所有相关的三维空间布局和视野渲染。其功能至关重要,**绝不能被跳过**。 | |
| **核心职责:** 在任何需要以三维空间布局来展示角色视野的回合末尾,视觉投影仪 **必须** 按顺序执行以下两个动作: | |
| 1. **`<visionThink>` 块:** | |
| * 目的: 提供一个**强制性的、详尽的**思考过程,解释为何需要生成视觉布局,以及如何根据**当前回合的事件**来构建它。 | |
| * 内容要求: | |
| * **精确识别**:列出所有需要被渲染的对象(角色、物品、地形等)。 | |
| * **严谨定位**:根据当前回合的叙述,为每个对象确定其精确的坐标 (`pos`) 和尺寸 (`dim`)。所有坐标均以**`<user>`的位置为原点(0,0,0)**。 | |
| * **合理分类**:为每个对象分配合适的类型 (`type`)。 | |
| * **数据来源**: 所有渲染信息必须**严格源于**本回合的叙述。**禁止凭空捏造**场景细节。 | |
| 2. **`<vision3d>` 块:** | |
| * 目的: 提供**精确的、可执行的**标签块,用于实际渲染在 `<visionThink>` 中规划好的场景。如果 `<visionThink>` 确定需要渲染,则此块**不得省略**。 | |
| * 操作限制: **只能**使用在 `<vision-syntax>` 中定义的标签和格式。 | |
| * 严格格式化规则: | |
| * **完全遵守**指定的标签和参数语法。 | |
| * 所有字符串值**必须**使用**双引号**包围 (例如, `content:"哥布林"`). | |
| * 所有参数名和类型名**必须**使用**英文** (例如, `pos`, `dim`, `type`). | |
| * 坐标和尺寸的数值之间用逗号 `,` 分隔。 | |
| * 参数之间用分号 `;` 分隔。 | |
| **关键放置:** `<visionThink>` 和 `<vision3d>` 块**必须总是成对出现**,并且**只能出现在回复的绝对末尾**,在所有叙事或情节内容之后。它们**绝不能**出现在初步思考或预计算阶段。 | |
| **失败条款:** 在回合事件明显需要进行视觉布局更新时,省略 `<visionThink>` 或 `<vision3d>` 块,是对视觉投影仪模块的**一次严重失败**。勤勉和完整性至关重要。 | |
| </rule> | |
| <vision-syntax> | |
| <!-- 定义三维视觉渲染的精确语法 --> | |
| <!-- 3D 空间视角 --> | |
| <vision3d size="数字"> | |
| <item>pos:X,Y,Z; dim:W,D,H; content:"文本"; type:"分类";</item> | |
| <!-- ... more items ... --> | |
| </vision3d> | |
| </vision-syntax> | |
| <coordinate-system-and-types> | |
| <!-- 定义坐标系和对象类型 --> | |
| <!-- 原点: <user>的位置永远是坐标系的原点 (0,0,0) --> | |
| **3D坐标系 (空间):** | |
| - `pos:X,Y,Z`: 对象的中心点坐标。 | |
| - `X`: 左右。正右负左。 | |
| - `Y`: 前后。正前负后。 | |
| - `Z`: 上下。正上负下。 | |
| - `dim:W,D,H`: 对象的尺寸。 | |
| - `W (Width)`: 沿X轴的宽度。 | |
| - `D (Depth)`: 沿Y轴的深度。 | |
| - `H (Height)`: 沿Z轴的高度。 | |
| **对象类型 (type):** | |
| - `友方 (Friendly)`: 盟友、友好的NPC等。 | |
| - `敌方 (Hostile)`: 敌人、具有威胁的实体。 | |
| - `中立 (Neutral)`: 不具有明显立场的NPC、动物等。 | |
| - `物品 (Item)`: 可交互的物品、道具、宝箱等。 | |
| - `地形 (Terrain)`: 静态的环境元素,如墙壁、柱子、障碍物等。 | |
| </coordinate-system-and-types> | |
| <example> | |
| <!-- 示例:演示了思考过程和对应的渲染标签 --> | |
| <!-- 场景叙述: 你(代号“凤凰”)正蹲伏在一张翻倒的重金属桌后,这里是“奇点实验室”被毁坏的服务器机房。在你正前方8米处,一名敌对士兵正以一个高大的服务器机柜为掩护。你的右侧,一条金属 catwalk(猫道)部分坍塌,形成了一个斜坡,另一名狙击手正占据着4米高的平台残余部分。你的盟友,阿里斯博士,正蜷缩在左前方远处的一组服务器后方,他身旁的墙壁上,一个破损的动力导管正迸射出危险的电火花。一个至关重要的“数据核心”掉落在你左手边的地板上。 --> | |
| <visionThink> | |
| <!-- | |
| 基于当前场景分析: | |
| 1. **场景设置**: 这是一个复杂的多层次战斗场景,包含高低差(地面、坍塌的猫道)、多种类型的实体(友方、敌方、物品)和掩体。必须使用 `<vision3d>` 进行渲染。设定视野范围 `size` 为25米,以清晰包含所有关键元素。 | |
| 2. **识别并定位对象**: | |
| - **观察者角色 (在这个场景是“凤凰”)**: 位于原点(0,0,0),作为所有坐标的参考。 | |
| - **翻倒的金属桌 (地形)**: 你正前方的掩体,假设它高1米,宽2米。`pos:0,1.5,0.5; dim:2,1,1;` | |
| - **地面士兵 (敌方)**: 正前方8米(Y=8)。假设他在桌子左侧探头,所以X为负。身高1.8米。`pos:-1,8,0.9; dim:0.8,0.8,1.8;` | |
| - **士兵掩体-服务器机柜 (地形)**: 在地面士兵前方,提供掩护。`pos:-1,9,1.5; dim:1,2,3;` | |
| - **猫道平台 (地形)**: 右侧,4米高。假设平台中心点在右侧10米(X=10),前方6米(Y=6)处。`pos:10,6,3.75; dim:6,8,0.5;` | |
| - **坍塌的斜坡 (地形)**: 连接地面和猫道平台,用一个倾斜的长方体来近似表示。`pos:6,3,1.75; dim:4,6,3.5;` | |
| - **狙击手 (敌方)**: 在猫道平台上。`pos:10,7,4.9; dim:0.8,0.8,1.8;` (4米平台+0.9米身高中心) | |
| - **阿里斯博士 (友方)**: 左前方远处,蜷缩着。`pos:-10,14,0.5; dim:0.8,0.8,1;` (蜷缩高度1米) | |
| - **博士掩体-服务器组 (地形)**: 在博士前方。`pos:-10,15,1.5; dim:4,2,3;` | |
| - **电火花导管 (地形)**: 博士旁边的墙上,设定墙在X=-12处。`pos:-12,14,2; dim:0.2,1,1;` | |
| - **数据核心 (物品)**: 在你左手边地板上。`pos:-2,1,0.05; dim:0.2,0.2,0.1;` | |
| --> | |
| </visionThink> | |
| <vision3d size="25"> | |
| <!-- Your Cover --> | |
| <item>pos:0,1.5,0.5; dim:2,1,1; content:"翻倒的桌子"; type:"地形";</item> | |
| <!-- Hostile Soldier on the ground floor --> | |
| <item>pos:-1,8,0.9; dim:0.8,0.8,1.8; content:"士兵"; type:"敌方";</item> | |
| <!-- Server Rack cover for the ground soldier --> | |
| <item>pos:-1,9,1.5; dim:1,2,3; content:"服务器机柜"; type:"地形";</item> | |
| <!-- Elevated Catwalk Platform --> | |
| <item>pos:10,6,3.75; dim:6,8,0.5; content:"猫道平台"; type:"地形";</item> | |
| <!-- Collapsed Ramp leading to the catwalk --> | |
| <item>pos:6,3,1.75; dim:4,6,3.5; content:"坍塌的斜坡"; type:"地形";</item> | |
| <!-- Hostile Sniper on the catwalk --> | |
| <item>pos:10,7,4.9; dim:0.8,0.8,1.8; content:"狙击手"; type:"敌方";</item> | |
| <!-- Dr. Aris, your ally --> | |
| <item>pos:-10,14,0.5; dim:0.8,0.8,1; content:"阿里斯博士"; type:"友方";</item> | |
| <!-- Server Racks providing cover for the doctor --> | |
| <item>pos:-10,15,1.5; dim:4,2,3; content:"服务器组"; type:"地形";</item> | |
| <!-- Damaged Power Conduit --> | |
| <item>pos:-12,14,2; dim:0.2,1,1; content:"电火花"; type:"地形";</item> | |
| <!-- The objective item --> | |
| <item>pos:-2,1,0.05; dim:0.2,0.2,0.1; content:"数据核心"; type:"物品";</item> | |
| </vision3d> | |
| </example> | |
| </dataTable-operatior-guide> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name 全局脚本 - 3D视野组件 (单 Canvas v10.1) | |
| // @version 10.1 | |
| // @description 将标签改为 3D Sprite,仅用一张 Canvas;解决多次 scale 累积问题;优化 resize、hover 效果;完善事件触发逻辑 | |
| // @author codeboy | |
| // @match */* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| /* -------------------- 配置 -------------------- */ | |
| const WIDGET_NAME = 'GlobalScript_Vision3D_SingleCanvas'; | |
| const VISION_TYPES = { | |
| '友方': { color: 0x28a745 }, | |
| '敌方': { color: 0xdc3545 }, | |
| '中立': { color: 0x6c757d }, | |
| '物品': { color: 0x007bff }, | |
| '地形': { color: 0x8B4513 } | |
| }; | |
| const USER_COLOR = 0xffd700; | |
| const FONT_FAMILY = 'Arial, "PingFang SC", "Microsoft YaHei", sans-serif'; | |
| /* -------------------- 调试日志 -------------------- */ | |
| let debugMode = false; // 可通过控制台设置 window.vision3dDebug = true | |
| function logDebug(...args) { | |
| if (debugMode || window.vision3dDebug) { | |
| console.log(`[${WIDGET_NAME}]`, ...args); | |
| } | |
| } | |
| /* ------------------ 依赖加载 ------------------ */ | |
| let libsReady = false; | |
| async function ensureThree() { | |
| if (libsReady) return true; | |
| const load = src => new Promise((r, j) => { | |
| if (document.querySelector(`script[src="${src}"]`)) return r(); | |
| const s = Object.assign(document.createElement('script'), { src }); | |
| s.onload = r; s.onerror = () => j(new Error(`load fail: ${src}`)); | |
| document.head.appendChild(s); | |
| }); | |
| try { | |
| await load('https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'); | |
| await load('https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js'); | |
| libsReady = true; return true; | |
| } catch (e) { | |
| console.error(WIDGET_NAME, e); return false; | |
| } | |
| } | |
| /* -------------------- 样式 -------------------- */ | |
| function injectCss() { | |
| if (document.getElementById(`${WIDGET_NAME}-style`)) return; | |
| const style = document.createElement('style'); | |
| style.id = `${WIDGET_NAME}-style`; | |
| style.textContent = ` | |
| .vision3d-container{position:relative;width:100%;max-width:600px;border:1px solid #444;border-radius:8px;margin:10px auto;aspect-ratio:1/1;overflow:hidden;cursor:grab} | |
| .vision3d-container:active{cursor:grabbing} | |
| .vision3d-canvas{position:absolute;top:0;left:0;width:100%!important;height:100%!important;display:block} | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| /* ------------ 工具:文字转 Sprite ------------ */ | |
| function makeTextSprite(text, { baseScale = 0.8, color = '#f0f0f0', bg = 'rgba(0,0,0,.75)', padding = 6 } = {}) { | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| ctx.font = `bold 48px ${FONT_FAMILY}`; // 先用大字号测宽 | |
| const textW = ctx.measureText(text).width; | |
| const textH = 48; | |
| canvas.width = textW + padding * 2; | |
| canvas.height = textH + padding * 2; | |
| // 重新以正确尺寸绘制 | |
| ctx.font = `bold 48px ${FONT_FAMILY}`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillStyle = bg; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = color; | |
| ctx.fillText(text, canvas.width / 2, canvas.height / 2); | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.minFilter = THREE.LinearFilter; // 防止离屏抽样模糊 | |
| const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }); | |
| const sprite = new THREE.Sprite(material); | |
| // 基础缩放:用像素转化为世界单位;此比例可按需调整 | |
| const pxRatio = 0.01; // 1px ≈ 0.01 world unit | |
| sprite.scale.set(canvas.width * pxRatio, canvas.height * pxRatio, 1).multiplyScalar(baseScale); | |
| // 附加自定义字段以便 hover 动态调整 | |
| sprite.userData = { baseScale: sprite.scale.clone(), canvas }; | |
| return sprite; | |
| } | |
| /* ------------ 运行时存储 & 清理 -------------- */ | |
| const active = new Map(); | |
| const renderTimers = new Map(); // 防抖计时器 | |
| function cleanup(id) { | |
| const st = active.get(id); if (!st) return; | |
| // 清理防抖计时器 | |
| const timer = renderTimers.get(id); | |
| if (timer) { | |
| clearTimeout(timer); | |
| renderTimers.delete(id); | |
| } | |
| cancelAnimationFrame(st.raf); | |
| st.container.removeEventListener('mousemove', st.onMouseMove); | |
| st.resizeObserver.disconnect(); | |
| st.renderer.dispose(); | |
| st.scene.traverse(o => { | |
| if (o.isMesh) { | |
| o.geometry?.dispose(); | |
| (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m?.dispose?.()); | |
| } | |
| if (o.isSprite) { | |
| o.material?.map?.dispose(); | |
| o.material?.dispose(); | |
| } | |
| }); | |
| active.delete(id); | |
| logDebug(`清理 3D 视野组件: ${id}`); | |
| } | |
| /* --------------- 渲染主逻辑 ------------------ */ | |
| async function render3D(id, $placeholder, worldSize, xml) { | |
| if (!await ensureThree()) return; | |
| /* ---------- 解析 <vision3d> 数据 ---------- */ | |
| const items = [...xml.matchAll(/<item>(.*?)<\/item>/gis)].map(m => { | |
| const obj = {}; m[1].split(';').forEach(p => { | |
| const [k, v] = p.split(':').map(s => s.trim()); | |
| if (k && v) obj[k] = v.replace(/"/g, ''); | |
| }); | |
| const [x, y, z] = (obj.pos || '0,0,0').split(',').map(Number); | |
| const [w, d, h] = (obj.dim || '1,1,1').split(',').map(Number); | |
| return { pos: { x, y, z }, dim: { w, d, h }, label: obj.content, type: obj.type || '中立' }; | |
| }); | |
| /* ------------------- DOM ------------------ */ | |
| $placeholder.html(`<div class="vision3d-container"><canvas class="vision3d-canvas"></canvas></div>`); | |
| const container = $placeholder.find('.vision3d-container')[0]; | |
| const canvas3D = container.querySelector('canvas'); | |
| /* ------------------- Three ---------------- */ | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); | |
| camera.position.set(worldSize * .8, worldSize * .8, worldSize * .8); | |
| const renderer = new THREE.WebGLRenderer({ canvas: canvas3D, antialias: true, alpha: true }); | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; controls.target.set(0, 0, 0); | |
| /* 网格与玩家 */ | |
| scene.add(new THREE.GridHelper(worldSize, worldSize, 0x555555, 0x555555)); | |
| const playerGeo = new THREE.ConeGeometry(.5, 2, 8).translate(0, 1, 0); | |
| const playerMesh = new THREE.Mesh(playerGeo, new THREE.MeshBasicMaterial({ color: USER_COLOR })); | |
| scene.add(playerMesh); | |
| const playerLabel = makeTextSprite('玩家', { baseScale: 1 }); | |
| playerLabel.position.set(0, 2.5, 0); | |
| scene.add(playerLabel); | |
| /* items mesh & label */ | |
| const meshes = []; | |
| items.forEach(it => { | |
| const color = VISION_TYPES[it.type]?.color || VISION_TYPES['中立'].color; | |
| const mesh = new THREE.Mesh( | |
| new THREE.BoxGeometry(it.dim.w, it.dim.h, it.dim.d), | |
| new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }) | |
| ); | |
| mesh.position.set(it.pos.x, it.pos.z + it.dim.h / 2, -it.pos.y); | |
| scene.add(mesh); | |
| const labelSprite = makeTextSprite(it.label, { baseScale: 0.9 }); | |
| labelSprite.position.copy(mesh.position).add(new THREE.Vector3(0, it.dim.h / 2 + .5, 0)); | |
| scene.add(labelSprite); | |
| mesh.userData = { labelSprite }; | |
| meshes.push(mesh); | |
| }); | |
| /* 自适应尺寸 */ | |
| function syncSize() { | |
| const { width, height } = container.getBoundingClientRect(); | |
| if (!width || !height) return; | |
| const s = Math.min(width, height); | |
| camera.aspect = 1; camera.updateProjectionMatrix(); | |
| renderer.setSize(s, s, false); | |
| } | |
| const resizeObserver = new ResizeObserver(syncSize); resizeObserver.observe(container); | |
| syncSize(); | |
| /* Hover 光线检测 */ | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| let hoverMesh = null; | |
| function onMouseMove(e) { | |
| const r = container.getBoundingClientRect(); | |
| mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; | |
| mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1; | |
| } | |
| container.addEventListener('mousemove', onMouseMove); | |
| /* 视距缩放函数 */ | |
| function updateLabelScale(sprite) { | |
| const camDist = camera.position.distanceTo(sprite.position); | |
| const scaleFactor = camDist * 0.04; // 调节粗细可见度;经验参数 | |
| sprite.scale.copy(sprite.userData.baseScale).multiplyScalar(scaleFactor); | |
| } | |
| /* 渲染循环 */ | |
| function loop() { | |
| controls.update(); | |
| // hover | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersect = raycaster.intersectObjects(meshes)[0]?.object || null; | |
| if (intersect !== hoverMesh) { | |
| if (hoverMesh) { | |
| hoverMesh.material.opacity = 0.9; | |
| hoverMesh.userData.labelSprite.material.opacity = 1.0; | |
| hoverMesh.userData.labelSprite.scale.copy(hoverMesh.userData.labelSprite.userData.baseScale); | |
| } | |
| hoverMesh = intersect; | |
| if (hoverMesh) { | |
| hoverMesh.material.opacity = 0.5; | |
| hoverMesh.userData.labelSprite.material.opacity = 1.2; | |
| hoverMesh.userData.labelSprite.scale.multiplyScalar(1.25); | |
| } | |
| } | |
| // 每帧动态调整文字大小 | |
| updateLabelScale(playerLabel); | |
| meshes.forEach(m => updateLabelScale(m.userData.labelSprite)); | |
| renderer.render(scene, camera); | |
| active.get(id).raf = requestAnimationFrame(loop); | |
| } | |
| /* 注册清理对象 */ | |
| active.set(id, { | |
| scene, renderer, container, onMouseMove, resizeObserver, raf: requestAnimationFrame(loop) | |
| }); | |
| logDebug(`创建 3D 视野组件: ${id}, items: ${items.length}, worldSize: ${worldSize}`); | |
| } | |
| /* --------------- 防抖渲染函数 --------------- */ | |
| function renderVisionMessageDebounced(messageId, $mesText, delay = 150) { | |
| // 清除之前的计时器 | |
| const existingTimer = renderTimers.get(messageId); | |
| if (existingTimer) { | |
| clearTimeout(existingTimer); | |
| } | |
| // 设置新的防抖计时器 | |
| const timer = setTimeout(() => { | |
| renderVisionMessage(messageId, $mesText, false); | |
| renderTimers.delete(messageId); | |
| }, delay); | |
| renderTimers.set(messageId, timer); | |
| logDebug(`防抖渲染消息: ${messageId}, 延迟: ${delay}ms`); | |
| } | |
| /* --------------- 立即渲染函数 --------------- */ | |
| function renderVisionMessageImmediate(messageId, $mesText) { | |
| // 清除防抖计时器(如果存在) | |
| const existingTimer = renderTimers.get(messageId); | |
| if (existingTimer) { | |
| clearTimeout(existingTimer); | |
| renderTimers.delete(messageId); | |
| } | |
| renderVisionMessage(messageId, $mesText, true); | |
| logDebug(`立即渲染消息: ${messageId}`); | |
| } | |
| /* --------------- 核心渲染函数 --------------- */ | |
| function renderVisionMessage(messageId, $mesText, isImmediate = false) { | |
| const msgObj = SillyTavern.chat[messageId]; | |
| if (!msgObj) { | |
| logDebug(`消息不存在: ${messageId}`); | |
| return; | |
| } | |
| const reg = /<vision3d size="([\d.]+)">(.*?)<\/vision3d>/s; | |
| const match = msgObj.mes.match(reg); | |
| // 如果消息已有组件但不再包含vision3d标签,清理旧实例 | |
| if (!match && active.has(messageId)) { | |
| cleanup(messageId); | |
| } | |
| // 如果找到vision3d标签,先清理旧实例再创建新实例 | |
| if (match) { | |
| cleanup(messageId); | |
| let html = msgObj.mes; | |
| const placeholderId = `v3d-${messageId}-${Date.now()}`; | |
| html = html.replace(reg, `<div id="${placeholderId}"></div>`); | |
| // 如果没有传入$mesText,尝试获取 | |
| if (!$mesText) { | |
| const mesElement = $(`#chat .mes[mesid="${messageId}"]`); | |
| if (mesElement.length) { | |
| $mesText = mesElement.find('.mes_text'); | |
| } | |
| } | |
| if ($mesText && $mesText.length) { | |
| $mesText.html(SillyTavern.messageFormatting(html, msgObj.name, msgObj.is_system, msgObj.is_user, messageId)); | |
| const renderDelay = isImmediate ? 0 : 50; // 立即渲染时不延迟 | |
| setTimeout(() => { | |
| const $placeholder = $(`#${placeholderId}`, $mesText); | |
| if ($placeholder.length) { | |
| render3D(messageId, $placeholder, +match[1], match[2]); | |
| } | |
| }, renderDelay); | |
| } | |
| } | |
| } | |
| /* --------------- 批量渲染函数 --------------- */ | |
| function renderAllVisionMessages() { | |
| logDebug('开始批量渲染所有vision3d消息'); | |
| $('#chat .mes[is_user="false"][is_system="false"]').each((_, el) => { | |
| const messageId = +el.getAttribute('mesid'); | |
| const $mesText = $('.mes_text', el); | |
| if (messageId && $mesText.length) { | |
| renderVisionMessageDebounced(messageId, $mesText, 100); | |
| } | |
| }); | |
| } | |
| /* --------------- 优化的事件监听设置 --------------- */ | |
| function setupOptimizedEventListeners() { | |
| if (!SillyTavern.eventTypes || !SillyTavern.eventSource) { | |
| logDebug('SillyTavern 事件系统未就绪'); | |
| return; | |
| } | |
| const eventTypes = SillyTavern.eventTypes; | |
| const eventSource = SillyTavern.eventSource; | |
| logDebug('设置优化的事件监听器'); | |
| // 消息生成状态追踪 | |
| let messageSentRecently = false; | |
| let messageSentResetTimer = null; | |
| // 监听消息发送事件 | |
| if (eventTypes.MESSAGE_SENT) { | |
| eventSource.on(eventTypes.MESSAGE_SENT, () => { | |
| logDebug('Event: MESSAGE_SENT'); | |
| messageSentRecently = true; | |
| if (messageSentResetTimer) { | |
| clearTimeout(messageSentResetTimer); | |
| } | |
| // 1秒后自动重置标志 | |
| messageSentResetTimer = setTimeout(() => { | |
| messageSentRecently = false; | |
| messageSentResetTimer = null; | |
| logDebug('自动重置 messageSentRecently 标志'); | |
| }, 1000); | |
| }); | |
| } | |
| // 生成开始时的条件渲染 | |
| if (eventTypes.GENERATION_STARTED) { | |
| eventSource.on(eventTypes.GENERATION_STARTED, () => { | |
| logDebug(`Event: GENERATION_STARTED (messageSentRecently: ${messageSentRecently})`); | |
| if (messageSentRecently) { | |
| // 获取最新消息进行防抖渲染 | |
| const latestMessage = SillyTavern.chat[SillyTavern.chat.length - 1]; | |
| if (latestMessage && !latestMessage.is_user) { | |
| renderVisionMessageDebounced(SillyTavern.chat.length - 1); | |
| } | |
| messageSentRecently = false; | |
| if (messageSentResetTimer) { | |
| clearTimeout(messageSentResetTimer); | |
| messageSentResetTimer = null; | |
| } | |
| } else { | |
| logDebug('跳过 GENERATION_STARTED 事件的渲染,因为不是由消息发送触发的'); | |
| } | |
| }); | |
| } | |
| // 生成结束时的立即渲染 | |
| if (eventTypes.GENERATION_ENDED) { | |
| eventSource.on(eventTypes.GENERATION_ENDED, () => { | |
| logDebug('Event: GENERATION_ENDED'); | |
| // 重置消息发送标志 | |
| messageSentRecently = false; | |
| if (messageSentResetTimer) { | |
| clearTimeout(messageSentResetTimer); | |
| messageSentResetTimer = null; | |
| } | |
| // 立即渲染最新的非用户消息 | |
| for (let i = SillyTavern.chat.length - 1; i >= 0; i--) { | |
| const msg = SillyTavern.chat[i]; | |
| if (msg && !msg.is_user && !msg.is_system) { | |
| renderVisionMessageImmediate(i); | |
| break; | |
| } | |
| } | |
| }); | |
| } | |
| // 消息接收时的立即渲染 | |
| if (eventTypes.MESSAGE_RECEIVED) { | |
| eventSource.on(eventTypes.MESSAGE_RECEIVED, (messageId) => { | |
| logDebug(`Event: MESSAGE_RECEIVED, messageId: ${messageId}`); | |
| const msg = SillyTavern.chat[messageId]; | |
| if (msg && !msg.is_user && !msg.is_system) { | |
| renderVisionMessageImmediate(messageId); | |
| } | |
| }); | |
| } | |
| // 消息切换事件 - 防抖渲染 | |
| if (eventTypes.MESSAGE_SWIPED) { | |
| eventSource.on(eventTypes.MESSAGE_SWIPED, (messageId) => { | |
| logDebug(`Event: MESSAGE_SWIPED, messageId: ${messageId}`); | |
| const msg = SillyTavern.chat[messageId]; | |
| if (msg && !msg.is_user && !msg.is_system) { | |
| renderVisionMessageDebounced(messageId, null, 200); | |
| } | |
| }); | |
| } | |
| // 消息更新事件 - 防抖渲染 | |
| if (eventTypes.MESSAGE_UPDATED) { | |
| eventSource.on(eventTypes.MESSAGE_UPDATED, (messageId) => { | |
| logDebug(`Event: MESSAGE_UPDATED, messageId: ${messageId}`); | |
| const msg = SillyTavern.chat[messageId]; | |
| if (msg && !msg.is_user && !msg.is_system) { | |
| renderVisionMessageDebounced(messageId, null, 150); | |
| } | |
| }); | |
| } | |
| // 消息删除事件 - 立即清理 | |
| if (eventTypes.MESSAGE_DELETED) { | |
| eventSource.on(eventTypes.MESSAGE_DELETED, (messageId) => { | |
| logDebug(`Event: MESSAGE_DELETED, messageId: ${messageId}`); | |
| cleanup(messageId); | |
| }); | |
| } | |
| // 批量消息删除事件 | |
| if (eventTypes.MESSAGES_DELETED) { | |
| eventSource.on(eventTypes.MESSAGES_DELETED, () => { | |
| logDebug('Event: MESSAGES_DELETED'); | |
| // 清理所有实例,稍后重新渲染 | |
| active.forEach((_, id) => cleanup(id)); | |
| setTimeout(renderAllVisionMessages, 300); | |
| }); | |
| } | |
| // 角色切换事件 | |
| if (eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED) { | |
| eventSource.on(eventTypes.CHARACTER_FIRST_MESSAGE_SELECTED, () => { | |
| logDebug('Event: CHARACTER_FIRST_MESSAGE_SELECTED'); | |
| // 清理所有旧实例 | |
| active.forEach((_, id) => cleanup(id)); | |
| // 重新渲染所有消息 | |
| setTimeout(renderAllVisionMessages, 200); | |
| }); | |
| } | |
| // 其他需要重新渲染的事件 - 防抖处理 | |
| const otherDebouncedEvents = [ | |
| eventTypes.IMAGE_SWIPED, | |
| eventTypes.MESSAGE_FILE_EMBEDDED, | |
| eventTypes.MESSAGE_REASONING_EDITED, | |
| eventTypes.MESSAGE_REASONING_DELETED, | |
| eventTypes.FILE_ATTACHMENT_DELETED, | |
| eventTypes.GROUP_UPDATED | |
| ].filter(Boolean); | |
| otherDebouncedEvents.forEach(eventType => { | |
| if (eventType) { | |
| eventSource.on(eventType, () => { | |
| logDebug(`Event (Other Debounced): ${eventType}`); | |
| setTimeout(renderAllVisionMessages, 300); | |
| }); | |
| } | |
| }); | |
| logDebug('优化的事件监听器设置完成'); | |
| } | |
| /* --------------- 页面卸载清理 --------------- */ | |
| function setupCleanupOnUnload() { | |
| window.addEventListener('beforeunload', () => { | |
| logDebug('页面卸载,清理所有3D视野组件'); | |
| active.forEach((_, id) => cleanup(id)); | |
| renderTimers.forEach(timer => clearTimeout(timer)); | |
| renderTimers.clear(); | |
| }); | |
| } | |
| /* ------------------ 启动脚本 ------------------ */ | |
| function waitReady() { | |
| if (typeof SillyTavern !== 'undefined' && | |
| SillyTavern.chat && | |
| SillyTavern.eventSource && | |
| SillyTavern.eventTypes) { | |
| logDebug('SillyTavern 环境就绪,初始化3D视野组件'); | |
| injectCss(); | |
| setupOptimizedEventListeners(); | |
| setupCleanupOnUnload(); | |
| // 初始渲染现有消息 | |
| setTimeout(renderAllVisionMessages, 500); | |
| // 暴露调试接口到全局 | |
| window.vision3dDebug = false; | |
| window.vision3dCleanup = (id) => { | |
| if (id !== undefined) { | |
| cleanup(id); | |
| } else { | |
| active.forEach((_, id) => cleanup(id)); | |
| } | |
| }; | |
| window.vision3dRenderAll = renderAllVisionMessages; | |
| logDebug('3D视野组件初始化完成'); | |
| } else { | |
| setTimeout(waitReady, 300); | |
| } | |
| } | |
| waitReady(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment