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 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)
@@ -0,0 +1,6 @@
1
+ # dsh-muv-table bundle patch: mounts the MUV table editor plugin.
2
+ # The plugin parses MUV character cards, renders Zod schemas as
3
+ # structured tables, and generates correct <UpdateVariable> blocks.
4
+ - insert:
5
+ - id: muv-table
6
+ name: 'dsh-muv-table'
@@ -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 输出 &lt;UpdateVariable&gt; 块时,变量会自动出现在这里。' }),
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
96
+ function escAttr(s) { return String(s==null?'':s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;') }
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
+ }