dsh-fishpai 1.0.2
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 +22 -0
- package/NOTICE.md +46 -0
- package/README.md +169 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2770 -0
- package/package.json +65 -0
- package/plugin/core/diff.mjs +208 -0
- package/plugin/core/markdown.mjs +450 -0
- package/plugin/core/notes.mjs +151 -0
- package/plugin/core/patch.mjs +93 -0
- package/plugin/core/render.mjs +906 -0
- package/plugin/core/runtime.mjs +83 -0
- package/plugin/core/theme-info.mjs +80 -0
- package/plugin/core/theme-spec.mjs +276 -0
- package/plugin/host/assets.mjs +120 -0
- package/plugin/host/custom-theme.mjs +121 -0
- package/plugin/host/routes.mjs +494 -0
- package/plugin/host/store.mjs +719 -0
- package/plugin/host/tools.mjs +573 -0
- package/plugin/index.mjs +127 -0
- package/plugin/vendor/highlight.min.js +1213 -0
- package/plugin/vendor/hljs-map.json +202 -0
- package/plugin/vendor/markdown-it.min.js +2 -0
- package/plugin/vendor/themes.js +414 -0
- package/skills/fishpai/SKILL.md +146 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型侧工具:`fishpai_open` / `fishpai_read` / `fishpai_write` / `fishpai_render` / `fishpai_theme`。
|
|
3
|
+
*
|
|
4
|
+
* 用 **raw JSON-Schema 注册**(`ctx.tools.register({name, description, parameters, output, execute})`),
|
|
5
|
+
* 不要 import `defineTool`——树外插件解析 `@deepseek-ai/dsh-tools` 不可靠(本机 dsh-ssh-tunnel /
|
|
6
|
+
* dsh-git-forge 都因此改用 raw 形态)。
|
|
7
|
+
*
|
|
8
|
+
* 工具文案本身就是给模型的纪律:写之前先读、写要带 base_revision、人改优先。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { render as renderCore, safeThemeKey } from '../core/render.mjs'
|
|
13
|
+
import { themes } from '../core/runtime.mjs'
|
|
14
|
+
import { buildCustomTheme, describeThemeSpec } from '../core/theme-spec.mjs'
|
|
15
|
+
import { splitBlocks } from '../core/markdown.mjs'
|
|
16
|
+
import { diffBlocks } from '../core/diff.mjs'
|
|
17
|
+
import { applyPatches } from '../core/patch.mjs'
|
|
18
|
+
import { attachPlaceholdersToBlocks, extractPlaceholders, reanchorNotes } from '../core/notes.mjs'
|
|
19
|
+
import * as store from './store.mjs'
|
|
20
|
+
import {
|
|
21
|
+
CUSTOM_THEME_KEY,
|
|
22
|
+
clearCustomTheme,
|
|
23
|
+
describeCustomTheme,
|
|
24
|
+
themeFor,
|
|
25
|
+
writeCustomTheme,
|
|
26
|
+
} from './custom-theme.mjs'
|
|
27
|
+
import { MAX_EMBED_BYTES, listLocalImages, listRemoteImages, makeImageResolver } from './assets.mjs'
|
|
28
|
+
|
|
29
|
+
const OUT_SCHEMA = {
|
|
30
|
+
type: 'object',
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
text: { type: 'string' },
|
|
34
|
+
doc_key: { type: 'string' },
|
|
35
|
+
isError: { type: 'boolean' },
|
|
36
|
+
},
|
|
37
|
+
required: ['text'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderText = (_args, value) => [{ type: 'text', text: value.text }]
|
|
41
|
+
|
|
42
|
+
function ok(text, docKey) {
|
|
43
|
+
return { text, ...(docKey ? { doc_key: docKey } : {}), isError: false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function err(text) {
|
|
47
|
+
return { text, isError: true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fmtTime(at) {
|
|
51
|
+
if (!at) return '未知时间'
|
|
52
|
+
const d = new Date(at)
|
|
53
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
54
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function anchor(block) {
|
|
58
|
+
if (!block) return '文档'
|
|
59
|
+
const where = block.headingPath && block.headingPath.length ? block.headingPath[block.headingPath.length - 1] : '开头'
|
|
60
|
+
const id = block.id || block.blockId || '?'
|
|
61
|
+
const start = block.startLine
|
|
62
|
+
const end = block.endLine
|
|
63
|
+
const kind = block.kind || '块'
|
|
64
|
+
return start ? `块 ${id}(第 ${start}-${end} 行 · ${kind} · §${where})` : `块 ${id}(${kind} · §${where})`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clip(text, max = 400) {
|
|
68
|
+
const s = String(text ?? '')
|
|
69
|
+
return s.length <= max ? s : `${s.slice(0, max)}…(截断)`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 会话身份与工作目录:工具必须知道自己在为哪个会话干活。 */
|
|
73
|
+
function sessionOf(deps, exec) {
|
|
74
|
+
const sessionId = exec && exec.agent && exec.agent.session ? exec.agent.session.id : null
|
|
75
|
+
if (!sessionId) throw new Error('无法确定调用方会话身份')
|
|
76
|
+
const cwd = deps.resolveCwd(sessionId)
|
|
77
|
+
if (!cwd) throw new Error('该会话没有工作目录,无法读写文档')
|
|
78
|
+
return { sessionId, cwd }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 定位文档:显式 doc_key 优先,否则用该会话当前打开的那一份。 */
|
|
82
|
+
function locate(deps, exec, args = {}) {
|
|
83
|
+
const { sessionId, cwd } = sessionOf(deps, exec)
|
|
84
|
+
const key = args.doc_key ? String(args.doc_key) : store.activeKey(cwd, sessionId)
|
|
85
|
+
if (!key) throw new Error('这个会话还没有打开任何鱼排文档,请先调用 fishpai_open')
|
|
86
|
+
const entry = store.readIndex(cwd).docs[key]
|
|
87
|
+
if (!entry) throw new Error(`没有这个文档:${key}`)
|
|
88
|
+
const abs = store.resolveInCwd(cwd, entry.path)
|
|
89
|
+
const state = store.readState(cwd, key)
|
|
90
|
+
if (!state) {
|
|
91
|
+
throw new Error(`文档状态缺失(.fishpai/state 可能被清理过):${key}。请重新 fishpai_open 这篇文档。`)
|
|
92
|
+
}
|
|
93
|
+
return { sessionId, cwd, key, abs, state }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readText(abs) {
|
|
97
|
+
try {
|
|
98
|
+
return fs.readFileSync(abs, 'utf8')
|
|
99
|
+
} catch {
|
|
100
|
+
return ''
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 注册五个工具。
|
|
106
|
+
* @param {object} ctx 宿主 cordis 上下文(需要 ctx.tools)
|
|
107
|
+
* @param {{resolveCwd: (sessionId: string) => string|null, log?: Function}} deps
|
|
108
|
+
* @returns {() => void} 全部工具的卸载函数
|
|
109
|
+
*/
|
|
110
|
+
export function registerTools(ctx, deps) {
|
|
111
|
+
const disposers = []
|
|
112
|
+
const register = (tool) => {
|
|
113
|
+
try {
|
|
114
|
+
const dispose = ctx.tools.register(tool)
|
|
115
|
+
disposers.push(typeof dispose === 'function' ? dispose : () => {})
|
|
116
|
+
} catch (error) {
|
|
117
|
+
deps.log?.(`[fishpai] 工具 ${tool.name} 注册失败: ${error.message}`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── fishpai_open ─────────────────────────────────────────────
|
|
122
|
+
register({
|
|
123
|
+
name: 'fishpai_open',
|
|
124
|
+
description:
|
|
125
|
+
'在 DSH 右侧栏打开「鱼排编辑器」公众号排版台,并把它指向一篇 Markdown。传 markdown 会新建文档并写入;传 path 打开工作区里已有的 .md(已存在的文件不会被覆盖)。文档打开后请让用户在侧栏直接改字或加批注(也可用 <!-- 鱼排: … --> 留占位),然后用 fishpai_read 拿到块级 diff 与批注,再用 fishpai_write 局部改稿。',
|
|
126
|
+
parameters: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
path: { type: 'string', description: '工作目录内的 .md 路径(可省略扩展名,会自动补 .md);省略则在 <工作目录>/.fishpai/docs/ 下按标题新建' },
|
|
130
|
+
markdown: { type: 'string', description: '初始 Markdown。目标文件已存在时不会覆盖,只打开现状' },
|
|
131
|
+
theme: { type: 'string', description: '主题 key 或中文名;省略则用 default(默认公众号,最适合粘进微信)。tech / gradient / dark_night 等风格更适合导出 HTML' },
|
|
132
|
+
},
|
|
133
|
+
required: [],
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
},
|
|
136
|
+
output: { schema: OUT_SCHEMA, render: renderText },
|
|
137
|
+
async execute(args, exec) {
|
|
138
|
+
try {
|
|
139
|
+
const { sessionId, cwd } = sessionOf(deps, exec)
|
|
140
|
+
const docPath = args.path
|
|
141
|
+
? store.resolveInCwd(cwd, store.withDefaultExt(String(args.path), '.md'))
|
|
142
|
+
: store.defaultDocPath(cwd, args.markdown)
|
|
143
|
+
const opened = store.openDoc({
|
|
144
|
+
cwd,
|
|
145
|
+
docPath,
|
|
146
|
+
markdown: args.markdown === undefined ? undefined : String(args.markdown),
|
|
147
|
+
theme: args.theme ? String(args.theme) : undefined,
|
|
148
|
+
by: 'ai',
|
|
149
|
+
})
|
|
150
|
+
store.setActive(cwd, sessionId, opened.key)
|
|
151
|
+
store.requestOpen({ cwd, docPath: opened.path, sessionId })
|
|
152
|
+
|
|
153
|
+
const rel = path.relative(cwd, opened.path).replace(/\\/g, '/')
|
|
154
|
+
const lines = [
|
|
155
|
+
opened.created ? `已新建文档并打开「鱼排编辑器」:${rel}` : `已打开已有文档:${rel}`,
|
|
156
|
+
`revision = ${opened.state.revision},主题 = ${opened.state.theme}`,
|
|
157
|
+
'右侧栏的「鱼排编辑器」面板已弹出(若没看到请点右侧栏顶部的鱼排图标)。',
|
|
158
|
+
]
|
|
159
|
+
if (opened.markdownIgnored && !opened.created) {
|
|
160
|
+
lines.push('注意:该文件已存在,你传入的 markdown 没有写入,避免覆盖用户内容。要改内容请用 fishpai_write。')
|
|
161
|
+
}
|
|
162
|
+
lines.push('接下来等用户在面板里改动/加批注,然后调用 fishpai_read 看 diff。')
|
|
163
|
+
return ok(lines.join('\n'), opened.key)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return err(`fishpai_open 失败:${error.message}`)
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ── fishpai_read ─────────────────────────────────────────────
|
|
171
|
+
register({
|
|
172
|
+
name: 'fishpai_read',
|
|
173
|
+
description:
|
|
174
|
+
'读取鱼排文档的当前状态:与「你上次写入的版本」之间的块级 diff、用户留下的批注与行内占位、以及全文。改稿之前必须先调用它——diff 告诉你用户到底改了什么、想要什么。',
|
|
175
|
+
parameters: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
doc_key: { type: 'string', description: '文档键;省略则读本会话当前打开的那一份' },
|
|
179
|
+
include: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
enum: ['full', 'summary', 'outline'],
|
|
182
|
+
description: 'full(默认)附全文;summary 只给 diff/批注/占位;outline 另附块清单(id + 首行)',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
required: [],
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
},
|
|
188
|
+
output: { schema: OUT_SCHEMA, render: renderText },
|
|
189
|
+
async execute(args, exec) {
|
|
190
|
+
try {
|
|
191
|
+
const { cwd, key, abs, state } = locate(deps, exec, args)
|
|
192
|
+
const markdown = readText(abs)
|
|
193
|
+
const blocks = splitBlocks(markdown)
|
|
194
|
+
const baseline = store.baselineContent(cwd, state)
|
|
195
|
+
const diff = diffBlocks(baseline, markdown)
|
|
196
|
+
const notes = reanchorNotes(state.notes, blocks)
|
|
197
|
+
const placeholders = attachPlaceholdersToBlocks(extractPlaceholders(markdown), blocks)
|
|
198
|
+
const images = listLocalImages({ markdown, cwd, docPath: abs })
|
|
199
|
+
const include = String(args.include || 'full')
|
|
200
|
+
|
|
201
|
+
const out = []
|
|
202
|
+
out.push(`文档:${store.docTitle(markdown, abs)}`)
|
|
203
|
+
out.push(`路径:${path.relative(cwd, abs).replace(/\\/g, '/')} revision = ${state.revision} 共 ${blocks.length} 块`)
|
|
204
|
+
const hasBaseline = !!state.baseline
|
|
205
|
+
if (hasBaseline) {
|
|
206
|
+
out.push(`你上次写入:rev ${state.baseline.rev} @ ${fmtTime(state.baseline.at)}(by ${state.baseline.by})`)
|
|
207
|
+
} else {
|
|
208
|
+
out.push('⚠ 这篇文档还没有基线(你还没写入过,或 .fishpai 状态被清理过)——"人改了什么"无法计算,请按下面的全文与批注处理。')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const changed = diff.entries
|
|
212
|
+
if (!changed.length) {
|
|
213
|
+
out.push(
|
|
214
|
+
hasBaseline
|
|
215
|
+
? '自你上次写入以来,用户没有改动正文。'
|
|
216
|
+
: '(没有基线,因此不显示正文差异;人留下的批注与行内占位见下。)',
|
|
217
|
+
)
|
|
218
|
+
} else {
|
|
219
|
+
out.push(`用户改动:${diff.stats.changed} 处改写 / ${diff.stats.added} 处新增 / ${diff.stats.removed} 处删除(共 ${diff.stats.blocksAfter} 块)`)
|
|
220
|
+
for (const e of diff.entries) {
|
|
221
|
+
if (e.status === 'rewritten') {
|
|
222
|
+
out.push(`[整篇重写] ${e.summary}`)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
out.push(`[${e.status}] ${anchor(e)}`)
|
|
226
|
+
if (e.before !== undefined) out.push(` 改前:${clip(e.before)}`)
|
|
227
|
+
if (e.after !== undefined) out.push(` 改后:${clip(e.after)}`)
|
|
228
|
+
}
|
|
229
|
+
if (diff.truncated) out.push('(差异过多,已折叠;如需定位具体块可先用 outline)')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const openNotes = notes.filter((n) => !n.resolved)
|
|
233
|
+
if (openNotes.length) {
|
|
234
|
+
out.push(`用户批注(${openNotes.length} 条):`)
|
|
235
|
+
for (const n of openNotes) {
|
|
236
|
+
const where = n.orphan ? `锚点已失效(原文片段:${clip(n.quote, 60)})` : anchor(blocks[n.blockIndex])
|
|
237
|
+
out.push(` - ${where}:${clip(n.text, 300)}`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (placeholders.length) {
|
|
241
|
+
out.push(`行内占位(${placeholders.length} 处,处理完请一并删掉):`)
|
|
242
|
+
for (const p of placeholders) out.push(` - 第 ${p.line} 行:${clip(p.text, 200)}`)
|
|
243
|
+
}
|
|
244
|
+
// 图片分三类说清楚:会内嵌的(粘过去自带)、内嵌不了的、外链的(微信正文不许引用)
|
|
245
|
+
const inlineOk = images.filter((i) => i.embed)
|
|
246
|
+
const notEmbedded = images.filter((i) => !i.embed)
|
|
247
|
+
const remoteImages = listRemoteImages({ markdown })
|
|
248
|
+
if (images.length || remoteImages.length) {
|
|
249
|
+
const parts = []
|
|
250
|
+
if (inlineOk.length) parts.push(`${inlineOk.length} 张本地图会内嵌 base64(粘进公众号编辑器时跟着一起过去,不用手动重传)`)
|
|
251
|
+
if (notEmbedded.length) parts.push(`${notEmbedded.length} 张本地图不会被内嵌`)
|
|
252
|
+
if (remoteImages.length) parts.push(`${remoteImages.length} 张外链图会被微信拦掉`)
|
|
253
|
+
out.push(`图片:${parts.join(';')}`)
|
|
254
|
+
for (const img of notEmbedded) {
|
|
255
|
+
const why =
|
|
256
|
+
img.status === 'missing'
|
|
257
|
+
? '文件不存在'
|
|
258
|
+
: img.status === 'too-large'
|
|
259
|
+
? `超过 ${Math.round(MAX_EMBED_BYTES / 1024 / 1024)}MB 内嵌上限`
|
|
260
|
+
: '在工作目录之外'
|
|
261
|
+
out.push(` - ⚠ ${img.src}:${why},粘过去可能不显示,要在编辑器里手动上传`)
|
|
262
|
+
}
|
|
263
|
+
for (const img of remoteImages) {
|
|
264
|
+
out.push(` - ⚠ ${img.src}:外链图,微信正文不允许引用,需要先下载再上传`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (include === 'outline') {
|
|
269
|
+
out.push('块清单:')
|
|
270
|
+
for (const b of blocks) out.push(` ${b.index} ${b.id} [${b.kind}] ${clip(b.text.split('\n')[0], 60)}`)
|
|
271
|
+
}
|
|
272
|
+
if (include === 'full') {
|
|
273
|
+
out.push('—— 当前全文 ——')
|
|
274
|
+
out.push(markdown)
|
|
275
|
+
}
|
|
276
|
+
out.push('—— 改稿提示:用 fishpai_write 的 mode:"patch" 只改你需要的块(block_id 见上),不要整篇重写,以免覆盖用户其余的手改。')
|
|
277
|
+
return ok(out.join('\n'), key)
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return err(`fishpai_read 失败:${error.message}`)
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ── fishpai_write ────────────────────────────────────────────
|
|
285
|
+
register({
|
|
286
|
+
name: 'fishpai_write',
|
|
287
|
+
description:
|
|
288
|
+
'按用户的最新改动改稿。必须带上你 read 到的 base_revision:如果用户在 read 之后又改过,写入会被拒绝并回带最新 diff(这是为了不覆盖人的手改)。优先用 mode:"patch" 只替换/插入/删除指定块,整篇重写只在确实需要时用 mode:"replace"。',
|
|
289
|
+
parameters: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
properties: {
|
|
292
|
+
doc_key: { type: 'string', description: '文档键;省略则写本会话当前打开的那一份' },
|
|
293
|
+
base_revision: { type: 'number', description: '你 read 到的 revision;不匹配则拒绝写入' },
|
|
294
|
+
mode: { type: 'string', enum: ['patch', 'replace'], description: 'patch=按块局部改(推荐);replace=整篇替换' },
|
|
295
|
+
markdown: { type: 'string', description: 'mode=replace 时的全文' },
|
|
296
|
+
patches: {
|
|
297
|
+
type: 'array',
|
|
298
|
+
description: 'mode=patch 时的块级补丁',
|
|
299
|
+
items: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
block_id: { type: 'string', description: '目标块 id(来自 fishpai_read 的 diff 或 outline)' },
|
|
303
|
+
block_index: { type: 'number', description: '目标块序号(block_id 的替代写法)' },
|
|
304
|
+
op: { type: 'string', enum: ['replace', 'insert_after', 'delete'], description: '改这块 / 在它后面插入 / 删掉它' },
|
|
305
|
+
markdown: { type: 'string', description: 'replace / insert_after 要写入的 Markdown' },
|
|
306
|
+
},
|
|
307
|
+
required: ['op'],
|
|
308
|
+
additionalProperties: false,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
required: ['base_revision', 'mode'],
|
|
313
|
+
additionalProperties: false,
|
|
314
|
+
},
|
|
315
|
+
output: { schema: OUT_SCHEMA, render: renderText },
|
|
316
|
+
async execute(args, exec) {
|
|
317
|
+
try {
|
|
318
|
+
const { cwd, key, abs, state } = locate(deps, exec, args)
|
|
319
|
+
const current = readText(abs)
|
|
320
|
+
const mode = String(args.mode)
|
|
321
|
+
|
|
322
|
+
// 先卡版本再看内容:版本不对时,块 id 也可能已经失效(人改了正文),
|
|
323
|
+
// 那种情况下报"找不到块"会误导模型,报"版本不匹配,请重新 read"才准确。
|
|
324
|
+
if (Number(args.base_revision) !== state.revision) {
|
|
325
|
+
const diff = diffBlocks(store.baselineContent(cwd, state), current)
|
|
326
|
+
const lines = [
|
|
327
|
+
`写入被拒绝:base_revision=${args.base_revision} 但当前是 ${state.revision}——文档在你 read 之后被改过。`,
|
|
328
|
+
'请重新 fishpai_read 拿到最新内容与块 id,再用新的 base_revision 提交,不要凭旧内容覆盖。',
|
|
329
|
+
]
|
|
330
|
+
if (diff.entries.length) {
|
|
331
|
+
lines.push('自你上次写入以来,文档里发生的改动:')
|
|
332
|
+
for (const e of diff.entries.slice(0, 10)) {
|
|
333
|
+
if (e.status === 'rewritten') {
|
|
334
|
+
lines.push(`[整篇重写] ${e.summary}`)
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
const where = e.blockId ? `块 ${e.blockId}` : '文档'
|
|
338
|
+
lines.push(`[${e.status}] ${where}${e.after !== undefined ? `:${clip(e.after, 200)}` : ''}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return err(lines.join('\n'))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let next = current
|
|
345
|
+
let applied = []
|
|
346
|
+
let patchErrors = []
|
|
347
|
+
if (mode === 'patch') {
|
|
348
|
+
const patches = Array.isArray(args.patches) ? args.patches : []
|
|
349
|
+
if (!patches.length) return err('mode=patch 需要 patches 数组;要么改用 mode=replace 并给 markdown')
|
|
350
|
+
const result = applyPatches(current, splitBlocks(current), patches)
|
|
351
|
+
next = result.markdown
|
|
352
|
+
applied = result.applied
|
|
353
|
+
patchErrors = result.errors
|
|
354
|
+
if (!applied.length) return err(`没有一个补丁能应用:\n${patchErrors.join('\n')}`)
|
|
355
|
+
} else if (mode === 'replace') {
|
|
356
|
+
if (typeof args.markdown !== 'string' || !args.markdown.trim()) return err('mode=replace 需要 markdown 全文')
|
|
357
|
+
next = args.markdown
|
|
358
|
+
} else {
|
|
359
|
+
return err(`未知 mode:${mode}`)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const saved = store.saveDoc({ cwd, docPath: abs, markdown: next, baseRevision: Number(args.base_revision), by: 'ai' })
|
|
363
|
+
if (!saved.ok) {
|
|
364
|
+
const diff = diffBlocks(saved.markdown, current)
|
|
365
|
+
const lines = [
|
|
366
|
+
`写入被拒绝:base_revision=${args.base_revision} 但当前是 ${saved.revision}——用户在 read 之后又改过文档。`,
|
|
367
|
+
'请重新 fishpai_read 之后再用新的 base_revision 提交,不要凭旧内容覆盖。',
|
|
368
|
+
]
|
|
369
|
+
if (diff.entries.length) {
|
|
370
|
+
lines.push('同时给你看看现在差在哪:')
|
|
371
|
+
for (const e of diff.entries.slice(0, 10)) {
|
|
372
|
+
lines.push(`[${e.status}] ${e.blockId ? `块 ${e.blockId}` : '文档'}${e.after !== undefined ? `:${clip(e.after, 200)}` : ''}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return err(lines.join('\n'))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const afterBlocks = splitBlocks(next)
|
|
379
|
+
const lines = [`已写入,revision ${state.revision} → ${saved.state.revision}`]
|
|
380
|
+
if (mode === 'patch') {
|
|
381
|
+
lines.push(`应用了 ${applied.length} 个补丁:${applied.map((a) => `${a.op}(${a.blockId})`).join('、')}`)
|
|
382
|
+
if (patchErrors.length) lines.push(`跳过的补丁:${patchErrors.join(';')}`)
|
|
383
|
+
} else {
|
|
384
|
+
lines.push(`整篇替换:${current.length} 字符 → ${next.length} 字符,共 ${afterBlocks.length} 块`)
|
|
385
|
+
}
|
|
386
|
+
lines.push('用户可在侧栏直接看到新版本;他们的下一次改动会出现在你的下一次 fishpai_read 里。')
|
|
387
|
+
return ok(lines.join('\n'), key)
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return err(`fishpai_write 失败:${error.message}`)
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// ── fishpai_render ───────────────────────────────────────────
|
|
395
|
+
register({
|
|
396
|
+
name: 'fishpai_render',
|
|
397
|
+
description:
|
|
398
|
+
'把文档渲染成可直接粘贴进微信公众号编辑器的自包含 HTML 文件(内联样式、本地图片内嵌 base64),落在工作目录里。用于交付、预览或归档;日常发布仍由用户在侧栏点「复制到公众号」或「导出」。',
|
|
399
|
+
parameters: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
doc_key: { type: 'string', description: '文档键;省略则用本会话当前打开的那一份(主题、字号等开关跟着它走)' },
|
|
403
|
+
markdown: { type: 'string', description: '直接渲染这段 Markdown(不读磁盘上的文件);本会话仍需已打开一篇文档提供样式设置——没有就先 fishpai_open' },
|
|
404
|
+
theme: { type: 'string', description: '主题 key 或中文名,覆盖文档当前设置' },
|
|
405
|
+
theme_spec: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
additionalProperties: true,
|
|
408
|
+
description:
|
|
409
|
+
'自定义主题(用 `fishpai_theme` 保存的那一套,或临时用这一次):{"name":"我的·灰底衬线","base":"elegant","styles":{"h2":"font-size: 19px;","blockquote":"background: #f4f4f5;"}}。' +
|
|
410
|
+
'只写想改的槽位,其余从 base 继承;base 省略时为 default。与 theme 同时给时以 theme_spec 为准。' +
|
|
411
|
+
'可用槽位与允许的 CSS 属性见技能 fishpai 的「自定义主题」一节(超出白名单会被拒绝并说明原因)。',
|
|
412
|
+
},
|
|
413
|
+
out_path: { type: 'string', description: '输出 HTML 路径(工作目录内;可省略扩展名,会自动补 .html);省略则与文档同名的 .html' },
|
|
414
|
+
embed_images: { type: 'boolean', description: '是否把本地图片内嵌成 base64,默认 true' },
|
|
415
|
+
publish: { type: 'boolean', description: 'true(默认)=「复制到公众号」形态;false = 预览原样 HTML' },
|
|
416
|
+
},
|
|
417
|
+
required: [],
|
|
418
|
+
additionalProperties: false,
|
|
419
|
+
},
|
|
420
|
+
output: { schema: OUT_SCHEMA, render: renderText },
|
|
421
|
+
async execute(args, exec) {
|
|
422
|
+
try {
|
|
423
|
+
const { cwd, key, abs, state } = locate(deps, exec, args)
|
|
424
|
+
const markdown = typeof args.markdown === 'string' ? args.markdown : readText(abs)
|
|
425
|
+
// 自定义主题(模型给的规格):校验成数据再合并到 base 上,**不求值任何模型给的代码**。
|
|
426
|
+
// 校验不过就直接失败——错误文案是写给模型看的,让它自己改对再来一次。
|
|
427
|
+
const custom = args.theme_spec ? buildCustomTheme(args.theme_spec, themes()) : null
|
|
428
|
+
// 主题名可能来自状态(那两套被移除的主题)、模型手写、或工作目录那套自定义主题
|
|
429
|
+
//(`custom` 要读磁盘):认不出就退回默认,别让导出整个失败。
|
|
430
|
+
const theme = custom ? custom.theme : themeFor({ cwd, name: args.theme ? String(args.theme) : state.theme }).theme
|
|
431
|
+
const out = renderCore(markdown, {
|
|
432
|
+
theme,
|
|
433
|
+
color: state.color || undefined,
|
|
434
|
+
font: state.font,
|
|
435
|
+
fontSize: state.fontSize,
|
|
436
|
+
footnotes: state.footnotes !== false,
|
|
437
|
+
macCodeBlock: state.macCodeBlock !== false,
|
|
438
|
+
publish: args.publish !== false,
|
|
439
|
+
// 导出的成品是要粘进公众号的,所以跟面板的「复制到公众号」走同一条兼容层:
|
|
440
|
+
// ① 把文字包进 <span>,避开微信结构校验对"含行内元素的段落"的误报;
|
|
441
|
+
// ② 把 background 简写拆成微信肯保留的长写,否则引用块/表头底色会被丢;
|
|
442
|
+
// ③ 把选定的字号推进到 p / li / 引用上(微信会剥掉最外层 wrapper 的样式)。
|
|
443
|
+
// publish:false(预览原样 HTML)时不加,保持与站点一致的原始形态。
|
|
444
|
+
wrapText: args.publish !== false,
|
|
445
|
+
wechatBackground: args.publish !== false,
|
|
446
|
+
promoteFontSize: args.publish !== false,
|
|
447
|
+
imageResolver: args.embed_images === false ? undefined : makeImageResolver({ cwd, docPath: abs }),
|
|
448
|
+
})
|
|
449
|
+
const target = args.out_path
|
|
450
|
+
? store.resolveInCwd(cwd, store.withDefaultExt(String(args.out_path), '.html'), { exts: ['.html', '.htm'] })
|
|
451
|
+
: store.resolveInCwd(cwd, path.join(path.dirname(abs), `${path.basename(abs, path.extname(abs))}.html`), { exts: ['.html', '.htm'] })
|
|
452
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
453
|
+
fs.writeFileSync(target, out.html, 'utf8')
|
|
454
|
+
const lines = [
|
|
455
|
+
`已导出 HTML:${path.relative(cwd, target).replace(/\\/g, '/')}`,
|
|
456
|
+
`主题「${out.themeName}」 ${out.html.length} 字符(约 ${Math.round(Buffer.byteLength(out.html, 'utf8') / 1024)} KB)`,
|
|
457
|
+
args.embed_images === false ? '本地图片未内嵌。' : '本地图片已内嵌 base64。',
|
|
458
|
+
]
|
|
459
|
+
if (custom) {
|
|
460
|
+
lines.push(describeThemeSpec(custom.spec))
|
|
461
|
+
lines.push(
|
|
462
|
+
'这套主题**没有保存**(只写进了这份 HTML):不要告诉用户"已经存进主题列表"。' +
|
|
463
|
+
'要把它变成面板里可点选的主题,等保存那一步(用户确认后再做)。',
|
|
464
|
+
)
|
|
465
|
+
for (const note of custom.spec.notes) lines.push(`提示:${note}`)
|
|
466
|
+
}
|
|
467
|
+
return ok(lines.join('\n'), key)
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return err(`fishpai_render 失败:${error.message}`)
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
// 走 `register()` 而不是直接 `ctx.tools.register()`:它把卸载函数收进 disposers,
|
|
475
|
+
// 插件卸载时才不会留下一个还挂着的工具(`plugin.mount.test.mjs` 会查这个)。
|
|
476
|
+
register({
|
|
477
|
+
name: 'fishpai_theme',
|
|
478
|
+
description:
|
|
479
|
+
'工作目录级的「自定义主题」:一个工作目录**只有一套**,存在 `.fishpai/theme.json`,' +
|
|
480
|
+
'面板的主题列表里就是那一个「自定义主题」占位(不是主题库,改一次覆盖一次)。' +
|
|
481
|
+
'action=set 写入并覆盖,同时把当前文档切到它(你能在面板里立刻看到);action=show 看现在这套(改它就在这份规格上改);' +
|
|
482
|
+
'action=clear 移除,文档退回默认主题。只想试一次、不落盘,就用 fishpai_render 的 theme_spec。',
|
|
483
|
+
parameters: {
|
|
484
|
+
type: 'object',
|
|
485
|
+
properties: {
|
|
486
|
+
action: { type: 'string', enum: ['set', 'show', 'clear'], description: '默认 show' },
|
|
487
|
+
theme_spec: {
|
|
488
|
+
type: 'object',
|
|
489
|
+
additionalProperties: true,
|
|
490
|
+
description:
|
|
491
|
+
'action=set 时必填:{"name":"我的·灰底衬线","base":"elegant","styles":{"blockquote":"background: #f4f4f5;"}}。' +
|
|
492
|
+
'只写想改的槽位,其余从 base 继承。可用槽位与允许的 CSS 属性见技能 fishpai 的「自定义主题」一节。',
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
required: [],
|
|
496
|
+
additionalProperties: false,
|
|
497
|
+
},
|
|
498
|
+
output: { schema: OUT_SCHEMA, render: renderText },
|
|
499
|
+
async execute(args, exec) {
|
|
500
|
+
try {
|
|
501
|
+
// 这个工具是**工作目录级**的,不需要先有一篇文档:只要会话有 cwd 就能用
|
|
502
|
+
const { sessionId, cwd } = sessionOf(deps, exec)
|
|
503
|
+
const action = String(args.action || 'show')
|
|
504
|
+
|
|
505
|
+
if (action === 'show') {
|
|
506
|
+
const current = describeCustomTheme(cwd)
|
|
507
|
+
if (!current) {
|
|
508
|
+
return ok('这个工作目录还没有自定义主题。用 action=set 写一套,面板主题列表里就会出现「自定义主题」。')
|
|
509
|
+
}
|
|
510
|
+
return ok(
|
|
511
|
+
[
|
|
512
|
+
`当前自定义主题「${current.spec.name}」(base=${current.spec.base})改了 ${Object.keys(current.spec.styles).length} 个槽位:`,
|
|
513
|
+
JSON.stringify({ name: current.spec.name, base: current.spec.base, styles: current.spec.styles }, null, 2),
|
|
514
|
+
'要改就在这份规格上改,然后 action=set 覆盖(只有这一套)。',
|
|
515
|
+
].join('\n'),
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 当前文档(可能没有):写/删之后顺手把文档状态对齐,别留下"选不中的主题"
|
|
520
|
+
const activeKey = store.activeKey(cwd, sessionId)
|
|
521
|
+
const activeEntry = activeKey ? store.readIndex(cwd).docs[activeKey] : null
|
|
522
|
+
const docPath = activeEntry ? store.resolveInCwd(cwd, activeEntry.path) : null
|
|
523
|
+
|
|
524
|
+
if (action === 'clear') {
|
|
525
|
+
const removed = clearCustomTheme(cwd)
|
|
526
|
+
let reverted = false
|
|
527
|
+
if (docPath && store.readState(cwd, activeKey)?.theme === CUSTOM_THEME_KEY) {
|
|
528
|
+
store.updateMeta({ cwd, docPath, meta: { theme: 'default' } })
|
|
529
|
+
reverted = true
|
|
530
|
+
}
|
|
531
|
+
return ok(
|
|
532
|
+
removed
|
|
533
|
+
? `已移除自定义主题${reverted ? ';当前文档已退回「默认公众号」' : ''}。`
|
|
534
|
+
: '本来就没有自定义主题,什么都没动。',
|
|
535
|
+
activeKey || undefined,
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (action !== 'set') return err(`未知 action:${action}(可用 set / show / clear)`)
|
|
540
|
+
|
|
541
|
+
const built = writeCustomTheme({ cwd, spec: args.theme_spec })
|
|
542
|
+
let switched = false
|
|
543
|
+
if (docPath) {
|
|
544
|
+
store.updateMeta({ cwd, docPath, meta: { theme: CUSTOM_THEME_KEY } })
|
|
545
|
+
switched = true
|
|
546
|
+
}
|
|
547
|
+
const lines = [
|
|
548
|
+
`已保存自定义主题「${built.spec.name}」(base=${built.spec.base},改了 ${Object.keys(built.spec.styles).length} 个槽位)。`,
|
|
549
|
+
switched
|
|
550
|
+
? '当前文档已切到它——面板里就能看到效果(预览与「复制到公众号」都走这套)。'
|
|
551
|
+
: '面板的主题列表里会多出「自定义主题」这一项,选它即可。',
|
|
552
|
+
'工作目录里只有这一套:再 set 一次就是覆盖,不会多出第二套。',
|
|
553
|
+
]
|
|
554
|
+
for (const note of built.spec.notes) lines.push(`提示:${note}`)
|
|
555
|
+
return ok(lines.join('\n'), activeKey || undefined)
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return err(`fishpai_theme 失败:${error.message}`)
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
return () => {
|
|
563
|
+
for (const dispose of disposers.reverse()) {
|
|
564
|
+
try {
|
|
565
|
+
dispose()
|
|
566
|
+
} catch {
|
|
567
|
+
/* 卸载失败不影响其它工具 */
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export { diffBlocks }
|