dsh-muv-table 0.1.0
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.
- package/LICENSE +86 -0
- package/README.md +60 -0
- package/cordis.patch.yml +6 -0
- package/lib/block-generator.js +57 -0
- package/lib/client.js +308 -0
- package/lib/index.js +333 -0
- package/lib/initvar-parser.js +101 -0
- package/lib/muv-parser.js +122 -0
- package/lib/panel.html +99 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# PolyForm Noncommercial Copyleft License 1.0.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chen731215-dev
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
In order to get any license under these terms, you must agree to them as both
|
|
8
|
+
strict obligations and conditions to all your licenses.
|
|
9
|
+
|
|
10
|
+
## Copyright License
|
|
11
|
+
|
|
12
|
+
The licensor grants you a copyright license for the software to do everything
|
|
13
|
+
you might do with the software that would otherwise infringe the licensor's
|
|
14
|
+
copyright for any purpose except for a commercial purpose.
|
|
15
|
+
|
|
16
|
+
## Noncommercial Purposes
|
|
17
|
+
|
|
18
|
+
A noncommercial purpose is any purpose that is not a commercial purpose.
|
|
19
|
+
|
|
20
|
+
A commercial purpose is one intended for commercial advantage or monetary
|
|
21
|
+
compensation. For purposes of this license, commercial advantage or monetary
|
|
22
|
+
compensation includes but is not limited to:
|
|
23
|
+
|
|
24
|
+
1. selling, licensing, or distributing the software as part of a commercial
|
|
25
|
+
product or service
|
|
26
|
+
|
|
27
|
+
2. using the software to provide a commercial service, including but not
|
|
28
|
+
limited to software-as-a-service, platform-as-a-service, or
|
|
29
|
+
infrastructure-as-a-service offerings
|
|
30
|
+
|
|
31
|
+
3. using the software in any manner that directly or indirectly generates
|
|
32
|
+
revenue
|
|
33
|
+
|
|
34
|
+
## Copyleft
|
|
35
|
+
|
|
36
|
+
With respect to any copies or modifications of the software that you make or
|
|
37
|
+
distribute, you must license them to recipients under the same terms as this
|
|
38
|
+
license. You must make available the source code of any modifications you make.
|
|
39
|
+
|
|
40
|
+
## Notices
|
|
41
|
+
|
|
42
|
+
You must ensure that everyone who gets a copy of any part of the software
|
|
43
|
+
from you also gets a copy of these terms or a link to
|
|
44
|
+
https://polyformproject.org/licenses/noncommercial-copyleft/1.0.0/.
|
|
45
|
+
|
|
46
|
+
## Patent License
|
|
47
|
+
|
|
48
|
+
The licensor grants you a patent license for the software that covers patent
|
|
49
|
+
claims the licensor can license, or becomes able to license, that you would
|
|
50
|
+
infringe by using the software.
|
|
51
|
+
|
|
52
|
+
## No Other Rights
|
|
53
|
+
|
|
54
|
+
These terms do not allow you to sublicense or transfer any of your licenses
|
|
55
|
+
to anyone else, or prevent the licensor from granting licenses to anyone else.
|
|
56
|
+
These terms do not imply any other licenses.
|
|
57
|
+
|
|
58
|
+
## No Liability
|
|
59
|
+
|
|
60
|
+
***As far as the law allows, the software comes as is, without any warranty
|
|
61
|
+
or condition, and the licensor will not be liable to you for any damages
|
|
62
|
+
arising out of these terms or the use or nature of the software, under any
|
|
63
|
+
kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the
|
|
68
|
+
**software** is the software the licensor makes available under these terms.
|
|
69
|
+
|
|
70
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
71
|
+
|
|
72
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of
|
|
73
|
+
organization that you work for, plus all organizations that have control over,
|
|
74
|
+
are under the control of, or are under common control with that organization.
|
|
75
|
+
Control means ownership of substantially all the assets of an entity, or the
|
|
76
|
+
power to direct its management and policies by vote, contract, or otherwise.
|
|
77
|
+
Control can be direct or indirect.
|
|
78
|
+
|
|
79
|
+
**Your licenses** are all the licenses granted to you for the software under
|
|
80
|
+
these terms.
|
|
81
|
+
|
|
82
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
83
|
+
|
|
84
|
+
**Modify** means to copy from or adapt all or part of the work in a fashion
|
|
85
|
+
requiring copyright permission, other than the making of an exact copy. The
|
|
86
|
+
resulting work is called a modification of the earlier work.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# 📊 dsh-muv-table
|
|
2
|
+
|
|
3
|
+
MUV 变量表格编辑器 —— 酒馆伴生插件。
|
|
4
|
+
|
|
5
|
+
> 🍺 本插件是 [dsh-tavern-v2](https://github.com/chen731215-dev/dsh-tavern-v2) 的扩展,需配合酒馆使用。
|
|
6
|
+
|
|
7
|
+
## 是什么
|
|
8
|
+
|
|
9
|
+
把酒馆角色卡的 MUV 变量渲染成结构化表格,Agent 直接编辑表格即可生成格式100%正确的 `<UpdateVariable>` 块,彻底避免 DeepSeek 漏变量、缩进错、标签不闭合的问题。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 前提:已安装 dsh-tavern-v2
|
|
15
|
+
dsh plugin --profile web add dsh-muv-table
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
或手动编辑 `~/.dsh/profiles/web/package.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"dsh-muv-table": "github:chen731215-dev/dsh-muv-table"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"profile": {
|
|
27
|
+
"bundles": ["dsh-muv-table"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 使用
|
|
34
|
+
|
|
35
|
+
### 1. 打开面板
|
|
36
|
+
左侧边栏点击「MUV 表格」,弹出变量编辑窗口。
|
|
37
|
+
|
|
38
|
+
### 2. 加载角色卡
|
|
39
|
+
- **自动同步**:面板打开时自动读取酒馆当前角色卡
|
|
40
|
+
- **手动加载**:点击「🍺 酒馆」按钮
|
|
41
|
+
- **拖放**:将角色卡 JSON 文件拖入面板
|
|
42
|
+
|
|
43
|
+
### 3. 编辑变量
|
|
44
|
+
- 点击容器行展开/折叠子变量
|
|
45
|
+
- 直接修改数值或文本
|
|
46
|
+
- 修改过的字段金色高亮
|
|
47
|
+
|
|
48
|
+
### 4. 生成 MUV 块
|
|
49
|
+
点击「⚡ 生成 MUV 块」→ 复制 → 粘贴到酒馆对话末尾。
|
|
50
|
+
|
|
51
|
+
## 支持的角色卡
|
|
52
|
+
|
|
53
|
+
| 类型 | 说明 |
|
|
54
|
+
|------|------|
|
|
55
|
+
| 有 `<initvar>` 默认数据 | 直接显示完整变量表 |
|
|
56
|
+
| 无 `<initvar>`(如瑟瑟提瓦特) | 显示空表,LLM 输出 UpdateVariable 后自动填充 |
|
|
57
|
+
|
|
58
|
+
## 协议
|
|
59
|
+
|
|
60
|
+
PolyForm-Noncommercial-Copyleft-1.0.0 — 详见 [LICENSE](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// MUV Block Generator: takes structured table data and generates
|
|
2
|
+
// a correctly formatted <UpdateVariable><initvar> block.
|
|
3
|
+
|
|
4
|
+
import { serializeInitvar } from './initvar-parser.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a complete <UpdateVariable> block from a nested data object.
|
|
8
|
+
* @param {object} data - The full variable data (nested object)
|
|
9
|
+
* @returns {string} The formatted <UpdateVariable><initvar>...</initvar></UpdateVariable> block
|
|
10
|
+
*/
|
|
11
|
+
export function generateMuvBlock(data) {
|
|
12
|
+
const inner = serializeInitvar(data)
|
|
13
|
+
return `<UpdateVariable>\n<initvar>\n${inner}</initvar>\n</UpdateVariable>`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply table edits to the data tree and generate the MUV block.
|
|
18
|
+
* @param {object} originalData - The original parsed initvar data
|
|
19
|
+
* @param {Array<{path: string, value: any}>} edits - Array of path-value edits
|
|
20
|
+
* @returns {string} The generated MUV block
|
|
21
|
+
*/
|
|
22
|
+
export function applyEditsAndGenerate(originalData, edits) {
|
|
23
|
+
const data = JSON.parse(JSON.stringify(originalData)) // deep clone
|
|
24
|
+
|
|
25
|
+
for (const edit of edits) {
|
|
26
|
+
setNestedValue(data, edit.path, edit.value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return generateMuvBlock(data)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Set a deeply nested value by dot-separated path.
|
|
34
|
+
* @param {object} obj
|
|
35
|
+
* @param {string} path - e.g. "Player.资产.黄金"
|
|
36
|
+
* @param {*} value
|
|
37
|
+
*/
|
|
38
|
+
function setNestedValue(obj, path, value) {
|
|
39
|
+
const keys = path.split('.')
|
|
40
|
+
let current = obj
|
|
41
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
42
|
+
const key = keys[i]
|
|
43
|
+
if (!(key in current)) {
|
|
44
|
+
current[key] = {}
|
|
45
|
+
}
|
|
46
|
+
current = current[key]
|
|
47
|
+
}
|
|
48
|
+
const lastKey = keys[keys.length - 1]
|
|
49
|
+
|
|
50
|
+
// Coerce types
|
|
51
|
+
if (typeof current[lastKey] === 'number' || (!isNaN(current[lastKey]) && current[lastKey] !== '' && current[lastKey] !== null && current[lastKey] !== undefined)) {
|
|
52
|
+
const num = Number(value)
|
|
53
|
+
current[lastKey] = isNaN(num) ? value : num
|
|
54
|
+
} else {
|
|
55
|
+
current[lastKey] = value
|
|
56
|
+
}
|
|
57
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// dsh-muv-table client v2: card-based tree view with modern DSH design
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: 'dsh-muv-table',
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
var module = { exports: {} }
|
|
6
|
+
var exports = module.exports
|
|
7
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
8
|
+
const React = require('react'), jsx = require('react/jsx-runtime'), ReactDOM = require('react-dom/client')
|
|
9
|
+
|
|
10
|
+
const ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="3" width="13" height="10" rx="1.5"/><path d="M1.5 6.5h13M6 6.5v6.5M11 6.5v6.5"/></svg>`
|
|
11
|
+
|
|
12
|
+
// ============ persistence ============
|
|
13
|
+
const SK = 'dsh-muv-table-state'
|
|
14
|
+
const loadSS = () => { try { const s=localStorage.getItem(SK); return s?JSON.parse(s):null } catch(_){return null} }
|
|
15
|
+
const saveSS = (s) => { try { localStorage.setItem(SK,JSON.stringify({...s,savedAt:Date.now()})) } catch(_){} }
|
|
16
|
+
let savedState = loadSS()
|
|
17
|
+
// ============ debounce utility ============
|
|
18
|
+
function debounce(fn, delay) {
|
|
19
|
+
let timer = null
|
|
20
|
+
return function (...args) {
|
|
21
|
+
if (timer) clearTimeout(timer)
|
|
22
|
+
timer = setTimeout(() => { timer = null; fn.apply(this, args) }, delay)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============ color tokens ============
|
|
27
|
+
const C = {
|
|
28
|
+
bg: 'var(--dsw-alias-bg-base)', bg1: 'var(--dsw-alias-bg-layer-1)', bg2: 'var(--dsw-alias-bg-layer-2)', bg3: 'var(--dsw-alias-bg-layer-3)',
|
|
29
|
+
border: 'var(--dsw-alias-border-l2)', borderL: 'var(--dsw-alias-border-l1)',
|
|
30
|
+
text: 'var(--dsw-alias-label-primary)', sub: 'var(--dsw-alias-label-secondary)', dim: 'var(--dsw-alias-label-tertiary)',
|
|
31
|
+
accent: 'var(--dsw-alias-label-accent)', gold: '#c5a065', danger: 'var(--dsw-alias-label-danger)',
|
|
32
|
+
hover: 'var(--dsw-specific-sidebar-nav-item-hover)',
|
|
33
|
+
font: 'var(--dsw-font-family)'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============ MuvTableView ============
|
|
37
|
+
function MuvTableView({ onClose }) {
|
|
38
|
+
const initial = savedState && savedState.cardName
|
|
39
|
+
? { status:'loaded', cardName:savedState.cardName, schemas:savedState.schemas, initvarData:savedState.initvarData, edits:savedState.edits||{}, generatedBlock:'', error:'', search:'' }
|
|
40
|
+
: { status:'idle', cardName:'', schemas:[], initvarData:{}, edits:{}, generatedBlock:'', error:'', search:'' }
|
|
41
|
+
const [state, setState] = React.useState(initial)
|
|
42
|
+
const [collapsed, setCollapsed] = React.useState(new Set())
|
|
43
|
+
const fileRef = React.useRef(null)
|
|
44
|
+
const hasAutoLoaded = React.useRef(false)
|
|
45
|
+
|
|
46
|
+
// Get the tavern's active card
|
|
47
|
+
const fetchTavernCard = async () => {
|
|
48
|
+
let url = '/api/muv-table/tavern-card'
|
|
49
|
+
if (lastPresetId) url += '?presetId=' + encodeURIComponent(lastPresetId)
|
|
50
|
+
const r = await fetch(url)
|
|
51
|
+
return r.json()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Auto-load from tavern on first open
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
if (!hasAutoLoaded.current && state.status === 'idle') {
|
|
57
|
+
hasAutoLoaded.current = true
|
|
58
|
+
loadFromTavern()
|
|
59
|
+
}
|
|
60
|
+
}, [state.status])
|
|
61
|
+
|
|
62
|
+
// Poll tavern card changes every 2s (debounced 500ms) → auto-resync when session or card changes
|
|
63
|
+
const lastSyncKey = React.useRef('')
|
|
64
|
+
const checkingRef = React.useRef(false)
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
if (state.status !== 'loaded' && state.status !== 'idle') return
|
|
67
|
+
const check = async () => {
|
|
68
|
+
if (checkingRef.current) return
|
|
69
|
+
checkingRef.current = true
|
|
70
|
+
try {
|
|
71
|
+
const d = await fetchTavernCard()
|
|
72
|
+
if (d.ok && d.name) {
|
|
73
|
+
const syncKey = d.name + '|' + (d.presetDir || '')
|
|
74
|
+
if (syncKey !== lastSyncKey.current || forceReload) {
|
|
75
|
+
forceReload = false
|
|
76
|
+
lastSyncKey.current = syncKey
|
|
77
|
+
savedState = { cardName:d.name, schemas:d.schemas, initvarData:d.initvarData, edits:{} }; saveSS(savedState)
|
|
78
|
+
setCollapsed(new Set())
|
|
79
|
+
setState({status:'loaded',cardName:d.name,schemas:d.schemas,initvarData:d.initvarData,edits:{},generatedBlock:'',error:'',search:''})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (_) {}
|
|
83
|
+
finally { checkingRef.current = false }
|
|
84
|
+
}
|
|
85
|
+
const debouncedCheck = debounce(check, 500)
|
|
86
|
+
check()
|
|
87
|
+
const timer = setInterval(debouncedCheck, 2000)
|
|
88
|
+
return () => clearInterval(timer)
|
|
89
|
+
}, [state.status])
|
|
90
|
+
|
|
91
|
+
const parseCard = async (json) => {
|
|
92
|
+
setState(s=>({...s,status:'loading',error:''}))
|
|
93
|
+
try {
|
|
94
|
+
const r = await fetch('/api/muv-table/parse-card',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(json)})
|
|
95
|
+
const d = await r.json(); if(!d.ok) throw new Error(d.error)
|
|
96
|
+
savedState = { cardName:d.name, schemas:d.schemas, initvarData:d.initvarData, edits:{} }; saveSS(savedState)
|
|
97
|
+
setCollapsed(new Set()); setState({status:'loaded',cardName:d.name,schemas:d.schemas,initvarData:d.initvarData,edits:{},generatedBlock:'',error:'',search:''})
|
|
98
|
+
} catch(e) { setState(s=>({...s,status:'error',error:e.message})) }
|
|
99
|
+
}
|
|
100
|
+
const readFile = (f) => { const r=new FileReader(); r.onload=()=>{ try{parseCard(JSON.parse(r.result))}catch(e){setState(s=>({...s,error:'JSON error: '+e.message}))} }; r.readAsText(f) }
|
|
101
|
+
const handleDrop = (e) => { e.preventDefault(); const f=e.dataTransfer.files[0]; if(f) readFile(f) }
|
|
102
|
+
const loadFromTavern = async () => {
|
|
103
|
+
setState(s=>({...s,status:'loading',error:''}))
|
|
104
|
+
try {
|
|
105
|
+
const d = await fetchTavernCard()
|
|
106
|
+
if(!d.ok) throw new Error(d.error)
|
|
107
|
+
const schemas = d.schemas || []
|
|
108
|
+
savedState = { cardName:d.name, schemas, initvarData:d.initvarData||{}, edits:{} }; saveSS(savedState)
|
|
109
|
+
setCollapsed(new Set()); setState({status:'loaded',cardName:d.name,schemas,initvarData:d.initvarData||{},edits:{},generatedBlock:'',error:'',search:''})
|
|
110
|
+
} catch(e) { setState(s=>({...s,status:'error',error:e.message})) }
|
|
111
|
+
}
|
|
112
|
+
const handleEdit = (path, val) => setState(s=>{ const e={...s.edits,[path]:val}; savedState={...savedState,edits:e}; saveSS(savedState); return {...s,edits:e} })
|
|
113
|
+
const handleGenerate = async () => {
|
|
114
|
+
const edits = Object.entries(state.edits).map(([p,v])=>({path:p,value:v}))
|
|
115
|
+
try {
|
|
116
|
+
const r = await fetch('/api/muv-table/generate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({originalData:state.initvarData,edits})})
|
|
117
|
+
const d = await r.json(); if(!d.ok) throw new Error(d.error)
|
|
118
|
+
setState(s=>({...s,generatedBlock:d.block}))
|
|
119
|
+
} catch(e) { setState(s=>({...s,error:e.message})) }
|
|
120
|
+
}
|
|
121
|
+
const toggleCollapse = (path) => setCollapsed(c=>{ const n=new Set(c); n.has(path)?n.delete(path):n.add(path); return n })
|
|
122
|
+
const toggleAll = () => setCollapsed(c=>c.size>0?new Set():new Set(state.schemas.map(f=>f.name)))
|
|
123
|
+
const editCount = Object.keys(state.edits).length
|
|
124
|
+
const typeName = { number:'数字', string:'文本', object:'对象', record:'字典' }
|
|
125
|
+
|
|
126
|
+
// ============ render a field node recursively ============
|
|
127
|
+
const renderField = (field, data, prefix, depth) => {
|
|
128
|
+
const path = prefix?prefix+'.'+field.name:field.name
|
|
129
|
+
const val = data?.[field.name]
|
|
130
|
+
const isContainer = field.type==='object' && field.children && field.children.length>0
|
|
131
|
+
const isCollapsed = collapsed.has(path)
|
|
132
|
+
const changed = state.edits[path]!==undefined
|
|
133
|
+
const indent = depth * 16
|
|
134
|
+
|
|
135
|
+
return jsx.jsxs('div', { key:path, children: [
|
|
136
|
+
// Row
|
|
137
|
+
jsx.jsxs('div', {
|
|
138
|
+
onClick: isContainer ? ()=>toggleCollapse(path) : undefined,
|
|
139
|
+
style: {
|
|
140
|
+
display:'flex', alignItems:'center', gap:8, padding:'5px 8px 5px '+(8+indent)+'px',
|
|
141
|
+
cursor: isContainer?'pointer':'default', userSelect:'none',
|
|
142
|
+
borderLeft: changed?'3px solid '+C.gold:'3px solid transparent',
|
|
143
|
+
background: changed?'rgba(197,160,101,0.06)':'transparent',
|
|
144
|
+
borderRadius: '0 6px 6px 0', marginBottom:1,
|
|
145
|
+
transition: 'background 0.12s'
|
|
146
|
+
},
|
|
147
|
+
onMouseEnter: e => { if(isContainer) e.currentTarget.style.background=changed?'rgba(197,160,101,0.12)':C.hover },
|
|
148
|
+
onMouseLeave: e => { e.currentTarget.style.background=changed?'rgba(197,160,101,0.06)':'transparent' },
|
|
149
|
+
children: [
|
|
150
|
+
jsx.jsx('span', { style:{width:14,fontSize:10,color:C.dim,flexShrink:0}, children: isContainer?(isCollapsed?'▸':'▾'):'' }),
|
|
151
|
+
jsx.jsx('span', { style:{fontWeight:isContainer?600:400,color:isContainer?C.text:C.sub,fontSize:13,flex:1,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}, children: field.name }),
|
|
152
|
+
jsx.jsx('span', { style:{fontSize:10,padding:'1px 6px',borderRadius:3,background:field.type==='number'?'rgba(74,191,171,0.15)':'rgba(197,160,101,0.1)',color:field.type==='number'?C.accent:C.sub,flexShrink:0}, children: typeName[field.type]||field.type }),
|
|
153
|
+
!isContainer && jsx.jsx('input', {
|
|
154
|
+
type: field.type==='number'?'number':'text',
|
|
155
|
+
value: state.edits[path]!==undefined?state.edits[path]:(val??''),
|
|
156
|
+
onChange: e => handleEdit(path, e.target.value),
|
|
157
|
+
onClick: e => e.stopPropagation(),
|
|
158
|
+
style: {
|
|
159
|
+
width: field.type==='number'?80:'100%', maxWidth:260,
|
|
160
|
+
background:C.bg3, border:'1px solid '+(changed?C.gold:'var(--dsw-alias-border-l2)'),
|
|
161
|
+
borderRadius:4, padding:'3px 8px', color:C.text, fontSize:12, fontFamily:'inherit', outline:'none',
|
|
162
|
+
flexShrink:1
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
]
|
|
166
|
+
}),
|
|
167
|
+
// Children
|
|
168
|
+
isContainer && !isCollapsed && field.children.map(c => renderField(c, val||{}, path, depth+1))
|
|
169
|
+
] })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============ main render ============
|
|
173
|
+
return jsx.jsxs('div', { style:{position:'fixed',inset:0,zIndex:60,background:'rgba(0,0,0,0.5)',display:'flex',alignItems:'center',justifyContent:'center'}, onClick:onClose, children: [
|
|
174
|
+
jsx.jsxs('div', { onClick:e=>e.stopPropagation(), style:{width:'min(92vw,820px)',height:'min(90vh,700px)',background:C.bg,display:'flex',flexDirection:'column',color:C.text,fontFamily:C.font,fontSize:13,borderRadius:12,border:'1px solid '+C.border,boxShadow:'0 20px 60px rgba(0,0,0,0.5)',overflow:'hidden'}, children: [
|
|
175
|
+
// Header
|
|
176
|
+
jsx.jsxs('div', { style:{display:'flex',alignItems:'center',gap:10,padding:'10px 16px',borderBottom:'1px solid '+C.border,flexShrink:0,background:C.bg1}, children: [
|
|
177
|
+
jsx.jsx('span', { style:{fontWeight:700,fontSize:15,flex:1}, children: state.cardName?'📊 '+state.cardName:'📊 MUV 变量表格' }),
|
|
178
|
+
state.status==='loaded' && jsx.jsx('input', { type:'text', placeholder:'🔍 搜索变量...', value:state.search, onChange:e=>setState(s=>({...s,search:e.target.value})), style:{width:160,padding:'4px 10px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,fontSize:12,fontFamily:'inherit',outline:'none'} }),
|
|
179
|
+
editCount>0 && jsx.jsx('span', { style:{fontSize:11,color:C.sub,background:'rgba(197,160,101,0.12)',padding:'2px 8px',borderRadius:10}, children: editCount+' 修改' }),
|
|
180
|
+
state.status==='loaded' && jsx.jsx('button', { onClick:loadFromTavern, style:{padding:'5px 10px',borderRadius:6,border:'1px solid '+C.gold,background:'rgba(197,160,101,0.1)',color:C.gold,cursor:'pointer',fontSize:11,fontFamily:'inherit'}, children: '🍺 酒馆' }),
|
|
181
|
+
state.status==='loaded' && jsx.jsx('button', { onClick:loadFromTavern, style:{padding:'5px 8px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:13,fontFamily:'inherit'}, children: '🔄' }),
|
|
182
|
+
state.status==='loaded' && jsx.jsx('button', { onClick:toggleAll, style:{padding:'5px 10px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:11,fontFamily:'inherit'}, children: collapsed.size>0?'📖 展开全部':'📕 折叠全部' }),
|
|
183
|
+
state.status==='loaded' && jsx.jsx('button', { onClick:handleGenerate, style:{padding:'7px 18px',borderRadius:8,border:'none',background:'linear-gradient(135deg,#c5a065,#d4a76a)',color:'#1a1a1a',cursor:'pointer',fontSize:13,fontWeight:700,fontFamily:'inherit',boxShadow:'0 2px 8px rgba(197,160,101,0.25)'}, children: '⚡ 生成 MUV 块' }),
|
|
184
|
+
jsx.jsx('button', { onClick:onClose, style:{padding:'5px 12px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:12,fontFamily:'inherit'}, children: '✕' })
|
|
185
|
+
] }),
|
|
186
|
+
|
|
187
|
+
// Content
|
|
188
|
+
state.status==='idle' && jsx.jsxs('div', { style:{flex:1,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:16,padding:24}, children: [
|
|
189
|
+
savedState && savedState.cardName && jsx.jsxs('div', { style:{background:C.bg2,border:'1px solid '+C.border,borderRadius:10,padding:'14px 24px',textAlign:'center',maxWidth:400,width:'100%'}, children: [
|
|
190
|
+
jsx.jsx('div', { style:{fontSize:14,fontWeight:600,marginBottom:4}, children: '📌 '+savedState.cardName }),
|
|
191
|
+
jsx.jsx('div', { style:{fontSize:11,color:C.dim,marginBottom:10}, children: savedState.schemas.length+' 个分组 · '+(savedState.edits?Object.keys(savedState.edits).length:0)+' 个修改' }),
|
|
192
|
+
jsx.jsx('button', { onClick:()=>{ setCollapsed(new Set()); setState({status:'loaded',cardName:savedState.cardName,schemas:savedState.schemas,initvarData:savedState.initvarData,edits:savedState.edits||{},generatedBlock:'',error:'',search:''}) }, style:{padding:'6px 20px',borderRadius:8,border:'none',background:'linear-gradient(135deg,#c5a065,#d4a76a)',color:'#1a1a1a',cursor:'pointer',fontSize:13,fontWeight:700,fontFamily:'inherit',boxShadow:'0 2px 8px rgba(197,160,101,0.25)'}, children: '📂 继续编辑' })
|
|
193
|
+
] }),
|
|
194
|
+
jsx.jsxs('div', { onClick:()=>fileRef.current?.click(), onDragOver:e=>e.preventDefault(), onDrop:handleDrop, style:{padding:40,border:'2px dashed '+C.border,borderRadius:12,textAlign:'center',cursor:'pointer',color:C.dim,maxWidth:400,width:'100%'}, children: [
|
|
195
|
+
jsx.jsx('div', { style:{fontSize:28,marginBottom:8}, children: '📁' }),
|
|
196
|
+
jsx.jsx('div', { children: '拖放 MUV 角色卡 JSON 到此处' }),
|
|
197
|
+
jsx.jsx('input', { ref:fileRef, type:'file', accept:'.json', style:{display:'none'}, onChange:e=>{const f=e.target.files[0];if(f)readFile(f)} })
|
|
198
|
+
] }),
|
|
199
|
+
jsx.jsx('button', { onClick:loadFromTavern, style:{padding:'10px 24px',borderRadius:8,border:'1px solid '+C.gold,background:'rgba(197,160,101,0.1)',color:C.gold,cursor:'pointer',fontSize:13,fontWeight:600,fontFamily:'inherit'}, children: '🍺 从酒馆加载当前角色卡' })
|
|
200
|
+
] }),
|
|
201
|
+
state.status==='loading' && jsx.jsx('div', { style:{flex:1,display:'flex',alignItems:'center',justifyContent:'center',color:C.dim}, children: '⏳ 解析中...' }),
|
|
202
|
+
state.error && jsx.jsx('div', { style:{padding:12,color:C.danger}, children: '⚠ '+state.error }),
|
|
203
|
+
|
|
204
|
+
// Tree
|
|
205
|
+
state.status==='loaded' && state.schemas.length===0 && jsx.jsxs('div', { style:{flex:1,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:12,padding:24,color:C.dim}, children: [
|
|
206
|
+
jsx.jsx('div', { style:{fontSize:14,fontWeight:600,color:C.text}, children: '📋 '+state.cardName }),
|
|
207
|
+
jsx.jsx('div', { style:{fontSize:12}, children: '此卡没有预设初始变量' }),
|
|
208
|
+
jsx.jsx('div', { style:{fontSize:11,textAlign:'center',lineHeight:1.6}, children: '玩起来后 LLM 输出 <UpdateVariable> 块时,变量会自动出现在这里。' }),
|
|
209
|
+
jsx.jsx('div', { style:{fontSize:11,color:C.gold}, children: '也可以手动拖放角色卡 JSON 文件加载初始数据。' })
|
|
210
|
+
] }),
|
|
211
|
+
state.status==='loaded' && state.schemas.length>0 && jsx.jsx('div', { style:{flex:1,overflow:'auto',padding:'8px 12px'}, children:
|
|
212
|
+
state.schemas.filter(f=>!state.search||f.name.includes(state.search)||JSON.stringify(f).includes(state.search)).map(field =>
|
|
213
|
+
jsx.jsxs('div', { key:field.name, style:{marginBottom:10,background:C.bg1,border:'1px solid '+C.border,borderRadius:8,overflow:'hidden'}, children: [
|
|
214
|
+
jsx.jsxs('div', { onClick:()=>toggleCollapse(field.name), style:{display:'flex',alignItems:'center',gap:8,padding:'8px 14px',cursor:'pointer',userSelect:'none',background:collapsed.has(field.name)?C.bg1:C.bg2,borderBottom:collapsed.has(field.name)?'none':'1px solid '+C.border}, onMouseEnter:e=>e.currentTarget.style.background=C.hover, onMouseLeave:e=>e.currentTarget.style.background=collapsed.has(field.name)?C.bg1:C.bg2, children: [
|
|
215
|
+
jsx.jsx('span', { style:{fontSize:12,color:C.dim,width:16}, children: collapsed.has(field.name)?'▸':'▾' }),
|
|
216
|
+
jsx.jsx('span', { style:{fontWeight:700,fontSize:14,flex:1}, children: field.name }),
|
|
217
|
+
jsx.jsx('span', { style:{fontSize:10,padding:'2px 8px',borderRadius:10,background:'rgba(197,160,101,0.12)',color:C.sub}, children: field.children.length+' 项' })
|
|
218
|
+
] }),
|
|
219
|
+
!collapsed.has(field.name) && jsx.jsx('div', { style:{padding:'4px 0'}, children:
|
|
220
|
+
field.children.map(c => renderField(c, state.initvarData[field.name]||{}, field.name, 1))
|
|
221
|
+
})
|
|
222
|
+
] })
|
|
223
|
+
)
|
|
224
|
+
}),
|
|
225
|
+
|
|
226
|
+
// Generated block
|
|
227
|
+
state.generatedBlock && jsx.jsxs('div', { style:{flexShrink:0,borderTop:'2px solid '+C.accent}, children: [
|
|
228
|
+
jsx.jsxs('div', { style:{display:'flex',alignItems:'center',gap:10,padding:'8px 14px',background:C.bg1}, children: [
|
|
229
|
+
jsx.jsx('span', { style:{fontWeight:600,fontSize:12,color:C.accent,flex:1}, children: '✅ MUV 块 ('+state.generatedBlock.split('\n').length+' 行)' }),
|
|
230
|
+
jsx.jsx('button', { onClick:()=>navigator.clipboard.writeText(state.generatedBlock), style:{padding:'4px 12px',borderRadius:4,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:11,fontFamily:'inherit'}, children: '📋 复制' }),
|
|
231
|
+
jsx.jsx('button', { onClick:()=>setState(s=>({...s,generatedBlock:''})), style:{padding:'4px 10px',borderRadius:4,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:11,fontFamily:'inherit'}, children: '✕' })
|
|
232
|
+
] }),
|
|
233
|
+
jsx.jsx('pre', { style:{background:'#0d0e12',padding:12,fontSize:11,fontFamily:'monospace',whiteSpace:'pre-wrap',wordBreak:'break-all',maxHeight:180,overflow:'auto',margin:0,color:'#c0c0c0',borderTop:'1px solid '+C.border}, children: state.generatedBlock })
|
|
234
|
+
] })
|
|
235
|
+
] })
|
|
236
|
+
] })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============ Sidebar entry ============
|
|
240
|
+
function sidebarRoot() { const col=document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]'); return col?col.querySelector('[class*="logoRow"]')?.parentElement??col.firstElementChild:undefined }
|
|
241
|
+
function newSessionBtn(root) { const n=root.querySelector('button[class*="newSession"]'); if(n)return n; for(const c of root.children) if(c.tagName==='BUTTON') return c }
|
|
242
|
+
function createEntry() {
|
|
243
|
+
const e=document.createElement('button'); e.type='button'; e.dataset.dshMuvEntry=''
|
|
244
|
+
const ex=document.querySelector('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'); e.className=ex?ex.className:''
|
|
245
|
+
e.setAttribute('aria-label','MUV 表格'); e.innerHTML=`<span style="flex:none;display:inline-flex;justify-content:center;align-items:center">${ICON}</span><span style="text-overflow:ellipsis;overflow:hidden">MUV 表格</span>`
|
|
246
|
+
Object.assign(e.style,{width:'100%',height:'32px',color:'var(--dsw-alias-label-secondary)',cursor:'pointer',whiteSpace:'nowrap',background:'none',border:'none',borderRadius:'8px',alignItems:'center',gap:'8px',padding:'0 12px',fontSize:'13px',display:'flex'})
|
|
247
|
+
e.addEventListener('mouseenter',()=>{e.style.background='var(--dsw-specific-sidebar-nav-item-hover)';e.style.color='var(--dsw-alias-label-primary)'})
|
|
248
|
+
e.addEventListener('mouseleave',()=>{e.style.background='none';e.style.color='var(--dsw-alias-label-secondary)'})
|
|
249
|
+
return e
|
|
250
|
+
}
|
|
251
|
+
function placeEntry(root, entry) {
|
|
252
|
+
const btn=newSessionBtn(root); if(!btn) return false
|
|
253
|
+
if(entry.parentElement!==root){const row=btn.closest('[class*="logoRow"]');const base=row&&row.parentElement===root?row:btn;const family=Array.from(root.children).filter(el=>el instanceof HTMLElement&&el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'));const anchor=family.length>0?family[0]:base.nextElementSibling;root.insertBefore(entry,anchor)}
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
let vc=null, vr=null
|
|
257
|
+
function showView(){if(vc){vc.style.display='';return};vc=document.createElement('div');vc.setAttribute('data-dsh-muv-view','');document.body.appendChild(vc);vr=ReactDOM.createRoot(vc);vr.render(jsx.jsx(MuvTableView,{onClose:()=>{if(vc)vc.style.display='none'}}))}
|
|
258
|
+
function mountSidebarEntry(){if(document.querySelector('[data-dsh-muv-entry]'))return()=>{};const e=createEntry();e.addEventListener('click',showView);let root,placed=false;const tp=()=>{if(root&&!root.isConnected){root=undefined;placed=false}if(placed){if(document.body.contains(e))return;root=undefined;placed=false}root??=sidebarRoot();if(!root)return;placed=placeEntry(root,e)};const o=new MutationObserver(tp);o.observe(document.body,{childList:true,subtree:true});tp();return()=>{o.disconnect();e.remove();if(vc)vc.remove();vr=null;vc=null}}
|
|
259
|
+
|
|
260
|
+
// ============ apply ============
|
|
261
|
+
let forceReload = false
|
|
262
|
+
let lastPresetId = ''
|
|
263
|
+
|
|
264
|
+
// ── DOM observer: detect session / preset switches by watching the URL ──
|
|
265
|
+
let _lastPath = window.location.pathname + window.location.search
|
|
266
|
+
function detectSessionChange() {
|
|
267
|
+
try {
|
|
268
|
+
const currentPath = window.location.pathname + window.location.search
|
|
269
|
+
if (currentPath !== _lastPath) {
|
|
270
|
+
_lastPath = currentPath
|
|
271
|
+
document.dispatchEvent(new CustomEvent('tavern-session-changed', {
|
|
272
|
+
detail: { path: currentPath }
|
|
273
|
+
}))
|
|
274
|
+
forceReload = true
|
|
275
|
+
}
|
|
276
|
+
} catch (_) {}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
exports.inject = []
|
|
280
|
+
exports.apply = function () {
|
|
281
|
+
// Listen for tavern preset changes (dispatched by other plugins or DSH internals)
|
|
282
|
+
const onPresetChanged = (e) => {
|
|
283
|
+
forceReload = true
|
|
284
|
+
lastPresetId = e.detail?.presetId || ''
|
|
285
|
+
}
|
|
286
|
+
document.addEventListener('tavern-preset-changed', onPresetChanged)
|
|
287
|
+
|
|
288
|
+
// Listen for tavern session changes
|
|
289
|
+
const onSessionChanged = () => {
|
|
290
|
+
forceReload = true
|
|
291
|
+
}
|
|
292
|
+
document.addEventListener('tavern-session-changed', onSessionChanged)
|
|
293
|
+
|
|
294
|
+
let d=null; const tm=()=>{if(d)return;if(sidebarRoot())d=mountSidebarEntry()}; tm()
|
|
295
|
+
const o=new MutationObserver(() => { tm(); detectSessionChange() })
|
|
296
|
+
o.observe(document.body,{childList:true,subtree:true})
|
|
297
|
+
// Initial detection
|
|
298
|
+
detectSessionChange()
|
|
299
|
+
return () => {
|
|
300
|
+
o.disconnect()
|
|
301
|
+
document.removeEventListener('tavern-preset-changed', onPresetChanged)
|
|
302
|
+
document.removeEventListener('tavern-session-changed', onSessionChanged)
|
|
303
|
+
if (d) d()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return module.exports
|
|
307
|
+
}
|
|
308
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// dsh-muv-table server half: provides API endpoints for parsing MUV cards,
|
|
2
|
+
// extracting Zod schemas, and generating <UpdateVariable> blocks.
|
|
3
|
+
|
|
4
|
+
import { parseMuvCard } from './muv-parser.js'
|
|
5
|
+
import { generateMuvBlock, applyEditsAndGenerate } from './block-generator.js'
|
|
6
|
+
import { parseInitvar } from './initvar-parser.js'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
let PANEL_HTML = ''
|
|
14
|
+
try { PANEL_HTML = fs.readFileSync(path.join(__dirname, 'panel.html'), 'utf8') } catch (_) {}
|
|
15
|
+
const PRESETS_ROOT = path.join(os.homedir(), '.dsh', '.agent-presets')
|
|
16
|
+
const TAVERN_PRESET_DIR = path.join(PRESETS_ROOT, 'tavern-lite')
|
|
17
|
+
const MUV_CARD_DIRS = ['E:/BaiduNetdiskDownload/EdgeDownload']
|
|
18
|
+
|
|
19
|
+
/** Read a YAML/yml file with encoding detection (GBK → UTF-8 fallback). */
|
|
20
|
+
function readYmlSafe(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(filePath)
|
|
23
|
+
const utf8 = raw.toString('utf8')
|
|
24
|
+
// If text looks garbled (multiple high bytes in a row), try GBK
|
|
25
|
+
if (/[\x80-\xFF]{3,}/.test(utf8)) {
|
|
26
|
+
try { return new TextDecoder('gbk').decode(raw) } catch (_) {}
|
|
27
|
+
}
|
|
28
|
+
return utf8
|
|
29
|
+
} catch (_) { return '' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract the character card name from an agent.cordis.yml persona text.
|
|
34
|
+
* Looks for 角色名/name markers or the persona's own title.
|
|
35
|
+
*/
|
|
36
|
+
function extractCardNameFromYml(yml) {
|
|
37
|
+
if (!yml) return ''
|
|
38
|
+
// 1) Find the persona text: |- block first (the actual character card content)
|
|
39
|
+
const textMatch = yml.match(/text:\s*\|-?\s*\n([\s\S]*?)(?=\n\s*[-a-zA-Z@_]+:|\n\s*$)/)
|
|
40
|
+
const body = textMatch ? textMatch[1] : yml
|
|
41
|
+
// 2) 角色名:xxx pattern (used by tavern cards)
|
|
42
|
+
const personaMatch = body.match(/角色名[::]\s*([^\s#\n]+)/)
|
|
43
|
+
if (personaMatch) return personaMatch[1].trim()
|
|
44
|
+
// 3) First meaningful line of the persona block (card title)
|
|
45
|
+
const firstLine = body.split(/\r?\n/).map(l => l.trim()).find(l => l && !l.startsWith('#') && !l.startsWith('-') && !l.startsWith('['))
|
|
46
|
+
if (firstLine) return firstLine.replace(/[#*_【】「」]/g, '').trim()
|
|
47
|
+
// 4) Upstream structured name: — but skip service names like '@deepseek-ai/dsh-persona'
|
|
48
|
+
const nameMatch = yml.match(/^\s*name:\s*(.+)$/m)
|
|
49
|
+
if (nameMatch) {
|
|
50
|
+
const n = nameMatch[1].trim().replace(/^['"]|['"]$/g, '')
|
|
51
|
+
if (n && !n.startsWith('@') && !n.includes('/')) return n
|
|
52
|
+
}
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scan all agent presets for card names.
|
|
58
|
+
* @returns {Array<{presetDir, cardName, yml, mtime}>}
|
|
59
|
+
*/
|
|
60
|
+
function scanAllPresetCards() {
|
|
61
|
+
const results = []
|
|
62
|
+
try {
|
|
63
|
+
const dirs = fs.readdirSync(PRESETS_ROOT, { withFileTypes: true })
|
|
64
|
+
.filter(d => d.isDirectory())
|
|
65
|
+
.map(d => path.join(PRESETS_ROOT, d.name))
|
|
66
|
+
// Also scan the DSH root (presets like anchored-standard live there too)
|
|
67
|
+
for (const dir of [...dirs, path.join(os.homedir(), '.dsh')]) {
|
|
68
|
+
const ymlPath = path.join(dir, 'agent.cordis.yml')
|
|
69
|
+
if (!fs.existsSync(ymlPath)) continue
|
|
70
|
+
try {
|
|
71
|
+
const yml = readYmlSafe(ymlPath)
|
|
72
|
+
const cardName = extractCardNameFromYml(yml)
|
|
73
|
+
const stat = fs.statSync(ymlPath)
|
|
74
|
+
results.push({ presetDir: path.basename(dir), cardName, yml, mtime: stat.mtimeMs })
|
|
75
|
+
} catch (_) {}
|
|
76
|
+
}
|
|
77
|
+
} catch (_) {}
|
|
78
|
+
return results
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve preset dir for a session id via session-bindings.json */
|
|
82
|
+
function presetDirForSession(sessionId) {
|
|
83
|
+
try {
|
|
84
|
+
const bindPath = path.join(os.homedir(), '.dsh', '.agent-presets', 'session-bindings.json')
|
|
85
|
+
if (sessionId && fs.existsSync(bindPath)) {
|
|
86
|
+
const bind = JSON.parse(fs.readFileSync(bindPath, 'utf8'))
|
|
87
|
+
const preset = bind[sessionId]
|
|
88
|
+
if (preset && preset !== 'default') return path.join(PRESETS_ROOT, preset)
|
|
89
|
+
}
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
return TAVERN_PRESET_DIR
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Search a person text for a matching card JSON in MUV dirs.
|
|
96
|
+
* Returns parsed card or null.
|
|
97
|
+
*/
|
|
98
|
+
function findJsonMatch(cardNameLower, dirs) {
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
if (!fs.existsSync(dir)) continue
|
|
101
|
+
for (const f of fs.readdirSync(dir).filter(f2 => f2.endsWith('.json'))) {
|
|
102
|
+
try {
|
|
103
|
+
const json = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
|
|
104
|
+
const name = json?.data?.name || json?.name || ''
|
|
105
|
+
if (!name) continue
|
|
106
|
+
if (cardNameLower && (name.toLowerCase().includes(cardNameLower) || cardNameLower.includes(name.toLowerCase()))) {
|
|
107
|
+
return { json, fileName: f }
|
|
108
|
+
}
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null // No fallback — if no exact match, use yml extraction
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract MUV card info from a preset's persona YAML/yml text.
|
|
117
|
+
* Looks for <initvar> blocks and builds a fake card object for parseMuvCard.
|
|
118
|
+
*/
|
|
119
|
+
function extractMuvFromYml(yml) {
|
|
120
|
+
if (!yml) return null
|
|
121
|
+
try {
|
|
122
|
+
const nameMatch = yml.match(/角色名[::]\s*([^\s#\n]+)/) || yml.match(/^\s*name:\s*(.+)$/m)
|
|
123
|
+
const name = nameMatch ? nameMatch[1].trim().replace(/^['"]|['"]$/g, '') : 'MUV Card'
|
|
124
|
+
// Try to find an <initvar> block in the persona
|
|
125
|
+
const m = yml.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
126
|
+
if (m) {
|
|
127
|
+
const blockText = m[1]
|
|
128
|
+
const parsed = parseInitvar(blockText)
|
|
129
|
+
const schemas = inferSchemas(parsed)
|
|
130
|
+
return { name, schemas, initvarData: parsed, initvarBlocks: [blockText], zodSource: '' }
|
|
131
|
+
}
|
|
132
|
+
// No initvar default — serve empty-but-valid so the panel opens empty
|
|
133
|
+
return { name, schemas: [], initvarData: {}, initvarBlocks: [], zodSource: '' }
|
|
134
|
+
} catch (_) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Infer schema tree from data (mirrors muv-parser.inferSchemaFromData). */
|
|
140
|
+
function inferSchemas(data) {
|
|
141
|
+
const fields = []
|
|
142
|
+
if (!data || typeof data !== 'object') return fields
|
|
143
|
+
for (const [key, value] of Object.entries(data)) {
|
|
144
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
145
|
+
const children = inferSchemas(value)
|
|
146
|
+
if (children.length > 0) fields.push({ name: key, type: 'object', defaultValue: '{}', description: '', children })
|
|
147
|
+
} else if (typeof value === 'number') {
|
|
148
|
+
fields.push({ name: key, type: 'number', defaultValue: String(value), description: '', children: [] })
|
|
149
|
+
} else {
|
|
150
|
+
fields.push({ name: key, type: 'string', defaultValue: String(value ?? ''), description: '', children: [] })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return fields
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const name = 'muv-table'
|
|
157
|
+
export const inject = ['webServer']
|
|
158
|
+
|
|
159
|
+
const MAX_BODY = 5 * 1024 * 1024 // 5MB for large cards like 苍玄界
|
|
160
|
+
|
|
161
|
+
function json(res, status, body) {
|
|
162
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
163
|
+
res.end(JSON.stringify(body))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readBody(req) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
let size = 0
|
|
169
|
+
const chunks = []
|
|
170
|
+
req.on('data', (chunk) => {
|
|
171
|
+
size += chunk.length
|
|
172
|
+
if (size > MAX_BODY) {
|
|
173
|
+
reject(new Error('body-too-large'))
|
|
174
|
+
req.destroy()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
chunks.push(chunk)
|
|
178
|
+
})
|
|
179
|
+
req.on('end', () => {
|
|
180
|
+
try {
|
|
181
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
182
|
+
} catch (e) {
|
|
183
|
+
reject(new Error('invalid-json'))
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
req.on('error', reject)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function apply(ctx) {
|
|
191
|
+
const routes = [
|
|
192
|
+
{
|
|
193
|
+
kind: 'exact',
|
|
194
|
+
path: '/muv-table',
|
|
195
|
+
handler: (req, res) => {
|
|
196
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
197
|
+
res.end(PANEL_HTML)
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
kind: 'exact',
|
|
202
|
+
path: '/api/muv-table/parse-card',
|
|
203
|
+
handler: async (req, res) => {
|
|
204
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
205
|
+
try {
|
|
206
|
+
const cardJson = await readBody(req)
|
|
207
|
+
const result = parseMuvCard(cardJson)
|
|
208
|
+
json(res, 200, { ok: true, ...result })
|
|
209
|
+
} catch (e) {
|
|
210
|
+
json(res, 400, { ok: false, error: e.message })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
kind: 'exact',
|
|
216
|
+
path: '/api/muv-table/parse-initvar',
|
|
217
|
+
handler: async (req, res) => {
|
|
218
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
219
|
+
try {
|
|
220
|
+
const { text } = await readBody(req)
|
|
221
|
+
if (!text) return json(res, 400, { ok: false, error: 'missing text field' })
|
|
222
|
+
const parsed = parseInitvar(text)
|
|
223
|
+
json(res, 200, { ok: true, data: parsed })
|
|
224
|
+
} catch (e) {
|
|
225
|
+
json(res, 400, { ok: false, error: e.message })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
kind: 'exact',
|
|
231
|
+
path: '/api/muv-table/generate',
|
|
232
|
+
handler: async (req, res) => {
|
|
233
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
234
|
+
try {
|
|
235
|
+
const { originalData, edits } = await readBody(req)
|
|
236
|
+
if (!originalData || !edits) {
|
|
237
|
+
return json(res, 400, { ok: false, error: 'missing originalData or edits' })
|
|
238
|
+
}
|
|
239
|
+
const block = applyEditsAndGenerate(originalData, edits)
|
|
240
|
+
json(res, 200, { ok: true, block })
|
|
241
|
+
} catch (e) {
|
|
242
|
+
json(res, 400, { ok: false, error: e.message })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
kind: 'exact',
|
|
248
|
+
path: '/api/muv-table/generate-raw',
|
|
249
|
+
handler: async (req, res) => {
|
|
250
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
251
|
+
try {
|
|
252
|
+
const { data } = await readBody(req)
|
|
253
|
+
if (!data) return json(res, 400, { ok: false, error: 'missing data' })
|
|
254
|
+
const block = generateMuvBlock(data)
|
|
255
|
+
json(res, 200, { ok: true, block })
|
|
256
|
+
} catch (e) {
|
|
257
|
+
json(res, 400, { ok: false, error: e.message })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
kind: 'exact',
|
|
263
|
+
path: '/api/muv-table/presets',
|
|
264
|
+
handler: (req, res) => {
|
|
265
|
+
try {
|
|
266
|
+
const presets = scanAllPresetCards()
|
|
267
|
+
json(res, 200, { ok: true, presets })
|
|
268
|
+
} catch (e) {
|
|
269
|
+
json(res, 500, { ok: false, error: e.message })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
kind: 'exact',
|
|
275
|
+
path: '/api/muv-table/tavern-card',
|
|
276
|
+
handler: async (req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(req.url, 'http://localhost')
|
|
279
|
+
const presetId = url.searchParams.get('presetId') || ''
|
|
280
|
+
|
|
281
|
+
// If a specific preset is requested, read from that preset's directory
|
|
282
|
+
let ymlPath, presetDir
|
|
283
|
+
if (presetId) {
|
|
284
|
+
presetDir = path.join(PRESETS_ROOT, presetId)
|
|
285
|
+
ymlPath = path.join(presetDir, 'agent.cordis.yml')
|
|
286
|
+
} else {
|
|
287
|
+
presetDir = TAVERN_PRESET_DIR
|
|
288
|
+
ymlPath = path.join(TAVERN_PRESET_DIR, 'agent.cordis.yml')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let yml = fs.existsSync(ymlPath) ? readYmlSafe(ymlPath) : ''
|
|
292
|
+
let cardName = extractCardNameFromYml(yml)
|
|
293
|
+
|
|
294
|
+
if (!cardName) {
|
|
295
|
+
const allPresets = scanAllPresetCards()
|
|
296
|
+
const nonEmpty = allPresets.filter(p => p.cardName).sort((a, b) => b.mtime - a.mtime)
|
|
297
|
+
if (nonEmpty.length > 0) {
|
|
298
|
+
cardName = nonEmpty[0].cardName
|
|
299
|
+
yml = nonEmpty[0].yml
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const match = findJsonMatch(String(cardName || '').toLowerCase(), MUV_CARD_DIRS)
|
|
304
|
+
|
|
305
|
+
if (match) {
|
|
306
|
+
const result = parseMuvCard(match.json)
|
|
307
|
+
json(res, 200, {
|
|
308
|
+
ok: true, found: true, fileName: match.fileName,
|
|
309
|
+
cardName, presetDir: path.basename(presetDir),
|
|
310
|
+
...result
|
|
311
|
+
})
|
|
312
|
+
} else if (cardName && yml) {
|
|
313
|
+
const parsed = extractMuvFromYml(yml)
|
|
314
|
+
json(res, 200, {
|
|
315
|
+
ok: true, found: true,
|
|
316
|
+
fileName: path.basename(presetDir) + '/agent.cordis.yml',
|
|
317
|
+
cardName, presetDir: path.basename(presetDir),
|
|
318
|
+
...parsed
|
|
319
|
+
})
|
|
320
|
+
} else {
|
|
321
|
+
json(res, 200, { ok: true, found: false, error: 'no card in preset' })
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
json(res, 500, { ok: false, error: e.message })
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
for (const route of routes) {
|
|
331
|
+
ctx.webServer.register(route)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// MUV Initvar parser: converts YAML-like indented initvar text to nested objects.
|
|
2
|
+
// Format: 2-space indentation, `键: 值` pairs. Supports nested objects and inline
|
|
3
|
+
// lists (comma-separated values wrapped in [ ]).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse an initvar text block into a nested object.
|
|
7
|
+
* @param {string} text - The initvar text (between <initvar> and </initvar>)
|
|
8
|
+
* @returns {object} Parsed nested object
|
|
9
|
+
*/
|
|
10
|
+
export function parseInitvar(text) {
|
|
11
|
+
const lines = text.split(/\r?\n/)
|
|
12
|
+
const root = {}
|
|
13
|
+
const stack = [{ obj: root, indent: -1 }]
|
|
14
|
+
|
|
15
|
+
for (const rawLine of lines) {
|
|
16
|
+
const line = rawLine.trimEnd()
|
|
17
|
+
if (line.trim() === '') continue
|
|
18
|
+
|
|
19
|
+
const indent = line.length - line.trimStart().length
|
|
20
|
+
const trimmed = line.trim()
|
|
21
|
+
|
|
22
|
+
// Pop stack until we find the right parent
|
|
23
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
24
|
+
stack.pop()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parent = stack[stack.length - 1].obj
|
|
28
|
+
const colonIdx = trimmed.indexOf(':')
|
|
29
|
+
|
|
30
|
+
if (colonIdx === -1) {
|
|
31
|
+
// Value-only line (continuation or list item)
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
36
|
+
const value = trimmed.slice(colonIdx + 1).trim()
|
|
37
|
+
|
|
38
|
+
if (value === '') {
|
|
39
|
+
// Nested object start
|
|
40
|
+
const newObj = {}
|
|
41
|
+
parent[key] = newObj
|
|
42
|
+
stack.push({ obj: newObj, indent })
|
|
43
|
+
} else {
|
|
44
|
+
// Leaf value
|
|
45
|
+
parent[key] = parseValue(value)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return root
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a leaf value string to appropriate type.
|
|
54
|
+
* @param {string} val
|
|
55
|
+
* @returns {string|number|boolean}
|
|
56
|
+
*/
|
|
57
|
+
function parseValue(val) {
|
|
58
|
+
// Empty string
|
|
59
|
+
if (val === '""' || val === "''") return ''
|
|
60
|
+
if (val === '{}') return '{}'
|
|
61
|
+
|
|
62
|
+
// Number
|
|
63
|
+
if (/^-?\d+(\.\d+)?$/.test(val)) {
|
|
64
|
+
return Number(val)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Boolean
|
|
68
|
+
if (val === 'true') return true
|
|
69
|
+
if (val === 'false') return false
|
|
70
|
+
|
|
71
|
+
return val
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Serialize a nested object back to initvar text.
|
|
76
|
+
* @param {object} obj
|
|
77
|
+
* @param {number} [depth=0]
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function serializeInitvar(obj, depth = 0) {
|
|
81
|
+
const indent = ' '.repeat(depth)
|
|
82
|
+
let result = ''
|
|
83
|
+
|
|
84
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
85
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
86
|
+
result += `${indent}${key}:\n`
|
|
87
|
+
result += serializeInitvar(value, depth + 1)
|
|
88
|
+
} else {
|
|
89
|
+
result += `${indent}${key}: ${formatValue(value)}\n`
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatValue(val) {
|
|
97
|
+
if (val === null || val === undefined) return ''
|
|
98
|
+
if (typeof val === 'boolean') return val ? 'true' : 'false'
|
|
99
|
+
if (typeof val === 'number') return String(val)
|
|
100
|
+
return String(val)
|
|
101
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// MUV Schema Parser: extracts variable structure from character card JSON.
|
|
2
|
+
// Uses initvar data to infer the schema tree (more reliable than regex-parsing Zod).
|
|
3
|
+
|
|
4
|
+
import { parseInitvar } from './initvar-parser.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a MUV character card JSON and extract schema + variable data.
|
|
8
|
+
* @param {object} cardJson - The full character card JSON
|
|
9
|
+
* @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function parseMuvCard(cardJson) {
|
|
12
|
+
const name = cardJson?.data?.name || cardJson?.name || 'Unnamed'
|
|
13
|
+
const extensions = cardJson?.data?.extensions
|
|
14
|
+
const tavernHelper = extensions?.tavern_helper
|
|
15
|
+
|
|
16
|
+
// Extract Zod schema source (for reference)
|
|
17
|
+
const zodScript = tavernHelper?.scripts?.find(s => s.name === 'zod')
|
|
18
|
+
const zodSource = zodScript?.content || ''
|
|
19
|
+
|
|
20
|
+
// Extract initvar blocks from alternate_greetings
|
|
21
|
+
const greetings = cardJson?.data?.alternate_greetings || []
|
|
22
|
+
const initvarBlocks = []
|
|
23
|
+
|
|
24
|
+
for (const greeting of greetings) {
|
|
25
|
+
if (typeof greeting !== 'string') continue
|
|
26
|
+
const match = greeting.match(/<initvar>([\s\S]*?)<\/initvar>/i)
|
|
27
|
+
if (match) {
|
|
28
|
+
initvarBlocks.push({
|
|
29
|
+
raw: match[1].trim(),
|
|
30
|
+
parsed: parseInitvar(match[1])
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Use the first initvar block as default data
|
|
36
|
+
const initvarData = initvarBlocks.length > 0 ? initvarBlocks[0].parsed : {}
|
|
37
|
+
|
|
38
|
+
// Infer schema from initvar data
|
|
39
|
+
const schemas = inferSchemaFromData(initvarData)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
schemas,
|
|
44
|
+
initvarData,
|
|
45
|
+
initvarBlocks: initvarBlocks.map(b => b.raw),
|
|
46
|
+
zodSource
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Infer a schema tree from a nested data object.
|
|
52
|
+
* @param {object} data
|
|
53
|
+
* @param {string} [name='']
|
|
54
|
+
* @returns {object[]}
|
|
55
|
+
*/
|
|
56
|
+
function inferSchemaFromData(data, name = '') {
|
|
57
|
+
if (!data || typeof data !== 'object') return []
|
|
58
|
+
|
|
59
|
+
const fields = []
|
|
60
|
+
for (const [key, value] of Object.entries(data)) {
|
|
61
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
62
|
+
// Nested object
|
|
63
|
+
const children = inferSchemaFromData(value, key)
|
|
64
|
+
if (children.length > 0) {
|
|
65
|
+
fields.push({
|
|
66
|
+
name: key,
|
|
67
|
+
type: 'object',
|
|
68
|
+
defaultValue: '{}',
|
|
69
|
+
description: '',
|
|
70
|
+
children
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
} else if (typeof value === 'number') {
|
|
74
|
+
fields.push({
|
|
75
|
+
name: key,
|
|
76
|
+
type: 'number',
|
|
77
|
+
defaultValue: String(value),
|
|
78
|
+
description: '',
|
|
79
|
+
children: []
|
|
80
|
+
})
|
|
81
|
+
} else {
|
|
82
|
+
fields.push({
|
|
83
|
+
name: key,
|
|
84
|
+
type: 'string',
|
|
85
|
+
defaultValue: String(value ?? ''),
|
|
86
|
+
description: '',
|
|
87
|
+
children: []
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return fields
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Walk the schema tree and extract values from data into flattened rows.
|
|
96
|
+
* @param {object[]} schemas
|
|
97
|
+
* @param {object} data
|
|
98
|
+
* @param {string} [prefix='']
|
|
99
|
+
* @returns {object[]}
|
|
100
|
+
*/
|
|
101
|
+
export function flattenSchemaData(schemas, data, prefix = '') {
|
|
102
|
+
const rows = []
|
|
103
|
+
for (const field of schemas) {
|
|
104
|
+
const path = prefix ? `${prefix}.${field.name}` : field.name
|
|
105
|
+
const value = data?.[field.name]
|
|
106
|
+
|
|
107
|
+
if (field.type === 'object' && field.children && field.children.length > 0) {
|
|
108
|
+
rows.push({ path, key: field.name, type: 'object', value: '(object)', isContainer: true, depth: prefix.split('.').filter(Boolean).length })
|
|
109
|
+
rows.push(...flattenSchemaData(field.children, value || {}, path))
|
|
110
|
+
} else {
|
|
111
|
+
rows.push({
|
|
112
|
+
path,
|
|
113
|
+
key: field.name,
|
|
114
|
+
type: field.type,
|
|
115
|
+
value: value ?? field.defaultValue ?? '',
|
|
116
|
+
isContainer: false,
|
|
117
|
+
depth: prefix.split('.').filter(Boolean).length
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return rows
|
|
122
|
+
}
|
package/lib/panel.html
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
6
|
+
<title>MUV 变量表格编辑器</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg: #1a1c24; --bg2: #22242e; --bg3: #2a2d38; --border: #3a3d48; --text: #e0e0e0; --sub: #8a8a8a; --accent: #4abfab; --gold: #c5a065; --radius: 6px; }
|
|
9
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
body { font-family: 'Segoe UI', 'Noto Sans SC', system-ui, sans-serif; background: var(--bg); color: var(--text); font-size: 13px; height: 100vh; display: flex; flex-direction: column; }
|
|
11
|
+
.header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; background: var(--bg2); }
|
|
12
|
+
.header .title { font-weight: 700; font-size: 15px; flex: 1; }
|
|
13
|
+
.btn { padding: 6px 14px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font-size: 12px; font-family: inherit; }
|
|
14
|
+
.btn-accent { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 600; }
|
|
15
|
+
.main { flex: 1; overflow: auto; display: flex; flex-direction: column; }
|
|
16
|
+
.drop-zone { margin: 24px auto; padding: 48px 32px; border: 2px dashed var(--border); border-radius: 12px; text-align: center; cursor: pointer; color: var(--sub); max-width: 480px; width: 90%; }
|
|
17
|
+
.drop-zone:hover { border-color: var(--gold); color: var(--gold); }
|
|
18
|
+
.drop-zone .icon { font-size: 36px; margin-bottom: 10px; }
|
|
19
|
+
.output-section { flex-shrink: 0; border-top: 2px solid var(--gold); }
|
|
20
|
+
.output-header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--bg2); }
|
|
21
|
+
.output-content { max-height: 260px; overflow: auto; padding: 12px 16px; background: #0d0e12; font-family: 'Consolas', monospace; font-size: 11px; white-space: pre-wrap; color: #c0c0c0; }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div class="header">
|
|
26
|
+
<span class="title">📊 MUV 变量表格编辑器</span>
|
|
27
|
+
<span id="badge" style="display:none;font-size:10px;padding:2px 8px;border-radius:10px;background:rgba(197,160,101,0.15);color:var(--gold)"></span>
|
|
28
|
+
<button class="btn" onclick="document.getElementById('fileInput').click()">📂 加载卡片</button>
|
|
29
|
+
<button id="btnGenerate" class="btn btn-accent" style="display:none" onclick="generateBlock()">⚡ 生成 MUV 块</button>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="main" id="main">
|
|
32
|
+
<div class="drop-zone" onclick="document.getElementById('fileInput').click()" ondragover="event.preventDefault()" ondrop="handleDrop(event)">
|
|
33
|
+
<div class="icon">📁</div>
|
|
34
|
+
<div>拖放 MUV 角色卡 JSON 文件到此处</div>
|
|
35
|
+
<div style="font-size:11px;margin-top:6px;opacity:0.7">或点击选择文件 · 支持 .json · 最大 5MB</div>
|
|
36
|
+
</div>
|
|
37
|
+
<input type="file" id="fileInput" accept=".json" style="display:none" onchange="handleFile(event)">
|
|
38
|
+
</div>
|
|
39
|
+
<div class="output-section" id="outputSection" style="display:none">
|
|
40
|
+
<div class="output-header">
|
|
41
|
+
<span style="font-weight:600;color:var(--gold);flex:1">✅ 生成的 MUV 块</span>
|
|
42
|
+
<button class="btn" onclick="copyBlock()">📋 复制</button>
|
|
43
|
+
<button class="btn" onclick="closeOutput()">✕</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="output-content" id="outputContent"></div>
|
|
46
|
+
</div>
|
|
47
|
+
<script>
|
|
48
|
+
let state = { cardName: '', schemas: [], initvarData: {}, edits: {}, generatedBlock: '' }
|
|
49
|
+
function handleDrop(e) { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) loadFile(f) }
|
|
50
|
+
function handleFile(e) { const f = e.target.files[0]; if (f) loadFile(f) }
|
|
51
|
+
function loadFile(f) { const r = new FileReader(); r.onload = () => { try { parseCard(JSON.parse(r.result)) } catch(err) { alert('JSON error: '+err.message) } }; r.readAsText(f) }
|
|
52
|
+
async function parseCard(json) {
|
|
53
|
+
document.getElementById('main').innerHTML = '<div style="padding:16px;color:var(--sub)">解析中...</div>'
|
|
54
|
+
try {
|
|
55
|
+
const r = await fetch('/api/muv-table/parse-card', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify(json) })
|
|
56
|
+
const d = await r.json(); if (!d.ok) throw new Error(d.error)
|
|
57
|
+
state = { cardName: d.name, schemas: d.schemas, initvarData: d.initvarData, edits: {}, generatedBlock: '' }
|
|
58
|
+
document.getElementById('badge').style.display = 'inline'; document.getElementById('badge').textContent = d.name
|
|
59
|
+
document.getElementById('btnGenerate').style.display = 'inline-block'
|
|
60
|
+
renderTable()
|
|
61
|
+
} catch(e) { document.getElementById('main').innerHTML = '<div style="padding:12px;color:var(--danger)">⚠ '+e.message+'</div>' }
|
|
62
|
+
}
|
|
63
|
+
function renderTable() {
|
|
64
|
+
let html = '<table style="width:100%;border-collapse:collapse"><thead><tr><th style="width:40%;text-align:left;padding:6px 10px;border-bottom:1px solid var(--border);font-size:11px;color:var(--sub)">变量</th><th style="width:60px;text-align:left;padding:6px 10px;border-bottom:1px solid var(--border);font-size:11px;color:var(--sub)">类型</th><th style="text-align:left;padding:6px 10px;border-bottom:1px solid var(--border);font-size:11px;color:var(--sub)">值</th></tr></thead><tbody>'
|
|
65
|
+
function flatten(schemas, data, prefix, depth) {
|
|
66
|
+
let rows = ''; prefix = prefix || ''; depth = depth || 0
|
|
67
|
+
for (const f of schemas) {
|
|
68
|
+
const p = prefix ? prefix+'.'+f.name : f.name; const v = data?.[f.name]
|
|
69
|
+
if (f.type==='object' && f.children && f.children.length>0) {
|
|
70
|
+
rows += '<tr><td style="padding:4px 10px;padding-left:'+(10+depth*18)+'px;font-weight:600">📂 '+esc(f.name)+'</td><td style="padding:4px 10px"><span style="font-size:10px;padding:1px 6px;border-radius:3px;background:rgba(138,138,138,0.12);color:var(--sub)">'+f.type+'</span></td><td style="padding:4px 10px;color:var(--sub);font-size:11px">▸ 展开</td></tr>'
|
|
71
|
+
rows += flatten(f.children, v||{}, p, depth+1)
|
|
72
|
+
} else {
|
|
73
|
+
const ev = state.edits[p]!==undefined ? state.edits[p] : (v??'')
|
|
74
|
+
rows += '<tr><td style="padding:4px 10px;padding-left:'+(10+depth*18)+'px;color:var(--sub)"> '+esc(f.name)+'</td><td style="padding:4px 10px"><span style="font-size:10px;padding:1px 6px;border-radius:3px;background:'+(f.type==='number'?'rgba(74,191,171,0.15)':'rgba(197,160,101,0.15)')+';color:'+(f.type==='number'?'var(--accent)':'var(--sub)')+'">'+f.type+'</span></td><td style="padding:4px 10px"><input type="'+(f.type==='number'?'number':'text')+'" value="'+escAttr(ev)+'" onchange="handleEdit(\''+escAttr(p)+'\',this.value)" style="background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:3px 8px;color:var(--text);font-size:12px;width:'+(f.type==='number'?'100px':'100%')+';max-width:260px"></td></tr>'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return rows
|
|
78
|
+
}
|
|
79
|
+
html += flatten(state.schemas, state.initvarData); html += '</tbody></table>'
|
|
80
|
+
document.getElementById('main').innerHTML = '<div style="flex:1;overflow:auto;padding:8px 12px">'+html+'</div>'
|
|
81
|
+
}
|
|
82
|
+
function handleEdit(path, val) { state.edits[path] = val }
|
|
83
|
+
async function generateBlock() {
|
|
84
|
+
const edits = Object.entries(state.edits).map(([p,v]) => ({path:p, value:v}))
|
|
85
|
+
try {
|
|
86
|
+
const r = await fetch('/api/muv-table/generate', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({originalData:state.initvarData, edits}) })
|
|
87
|
+
const d = await r.json(); if (!d.ok) throw new Error(d.error)
|
|
88
|
+
state.generatedBlock = d.block
|
|
89
|
+
document.getElementById('outputSection').style.display = 'block'
|
|
90
|
+
document.getElementById('outputContent').textContent = d.block
|
|
91
|
+
} catch(e) { alert(e.message) }
|
|
92
|
+
}
|
|
93
|
+
async function copyBlock() { await navigator.clipboard.writeText(state.generatedBlock) }
|
|
94
|
+
function closeOutput() { document.getElementById('outputSection').style.display = 'none' }
|
|
95
|
+
function esc(s) { return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }
|
|
96
|
+
function escAttr(s) { return String(s==null?'':s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') }
|
|
97
|
+
</script>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-muv-table",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MUV Variable Table Editor - tavern companion plugin for DSH: structured table editing for UpdateVariable blocks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"muv",
|
|
21
|
+
"magvarupdate",
|
|
22
|
+
"tavern",
|
|
23
|
+
"variable-table"
|
|
24
|
+
],
|
|
25
|
+
"license": "PolyForm-Noncommercial-Copyleft-1.0.0",
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"repository": {"type": "git", "url": "git+https://github.com/chen731215-dev/dsh-muv-table.git"},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
}
|
|
38
|
+
}
|