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,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文档存储:一份 Markdown + 它的 FishPai 状态(主题、revision、baseline、批注、历史)。
|
|
3
|
+
*
|
|
4
|
+
* 设计取舍:
|
|
5
|
+
* - **文档本体是普通 .md 文件**,人可以用任何编辑器改,模型也能直接 read;鱼排不往正文里塞标记。
|
|
6
|
+
* - 批注、主题、baseline 放在 `<cwd>/.fishpai/state/<docKey>.json` 的 sidecar 里,
|
|
7
|
+
* `docKey = sha1(小写绝对路径)[:12]`,所以人把文件挪走/改名不会张冠李戴。
|
|
8
|
+
* - **baseline = AI 上一次写入的版本**。人在面板里改字**不移动** baseline,
|
|
9
|
+
* 于是下一次 `fishpai_read` 拿到的恰好就是"人改了什么"。
|
|
10
|
+
* - 每次写入先把旧内容存进 history(可在面板里回滚),revision 单调递增。
|
|
11
|
+
* - 所有路径都必须落在会话工作目录内,且过扩展名白名单(见 resolveInCwd)。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
// 字号的形态规则只有一份,放在渲染层(它是那个字段的消费方,渲染层自己也必须兜底)
|
|
17
|
+
import { isFontSize } from '../core/render.mjs'
|
|
18
|
+
|
|
19
|
+
export const DOC_EXTS = ['.md', '.markdown', '.txt']
|
|
20
|
+
export const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']
|
|
21
|
+
export const STATE_VERSION = 1
|
|
22
|
+
|
|
23
|
+
/** 粘贴进来的图落在这个子目录(与文档同级,跟着文档一起走)。 */
|
|
24
|
+
export const ASSET_DIR = 'assets'
|
|
25
|
+
/**
|
|
26
|
+
* 单张图片上限(存进来与内嵌进剪贴板共用一处定义)。
|
|
27
|
+
* 再大微信编辑器自己也吃不下,而且内嵌会让剪贴板内容胖到卡住,不如当场说清楚。
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_ASSET_BYTES = 5 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
const MIME = {
|
|
32
|
+
'.png': 'image/png',
|
|
33
|
+
'.jpg': 'image/jpeg',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.svg': 'image/svg+xml',
|
|
38
|
+
'.bmp': 'image/bmp',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function mimeFor(file) {
|
|
42
|
+
return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 把会话工作目录规范化成真实路径(展开 8.3 短名、解掉链接)。
|
|
47
|
+
*
|
|
48
|
+
* 必须在一开始就统一口径:`resolveInCwd` 做守卫时会 realpath,
|
|
49
|
+
* 若调用方仍拿着原始写法(Windows 上可能是 `C:\Users\ADMINI~1\...`),
|
|
50
|
+
* 两边的 `path.relative` 会算出奇怪的 `..\..\..\Administrator\...`。
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalCwd(cwd) {
|
|
53
|
+
const abs = path.resolve(cwd)
|
|
54
|
+
return realOrSelf(abs)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 路径守卫 ────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function realOrSelf(p) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.realpathSync.native(p)
|
|
62
|
+
} catch {
|
|
63
|
+
return p
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 路径容错:只写了名字、没写扩展名时补一个默认的(`.md` / `.html`)。
|
|
69
|
+
*
|
|
70
|
+
* 只补**完全没有扩展名**的情形——`a.bak`、`a.` 这类照旧报错,免得把一次笔误
|
|
71
|
+
* 变成"安静地新建了一个同名不同扩展名的文件"。补完仍要过 `resolveInCwd` 的白名单,
|
|
72
|
+
* 所以这不是放宽守卫,只是让「写个名字」这种最常见的写法不至于白跑一趟。
|
|
73
|
+
*/
|
|
74
|
+
export function withDefaultExt(target, dflt) {
|
|
75
|
+
const value = typeof target === 'string' ? target.trim() : ''
|
|
76
|
+
if (!value) return value
|
|
77
|
+
if (path.extname(value) !== '') return value
|
|
78
|
+
if (/[\\/]$/.test(value)) return value // 结尾是分隔符:像是想指目录,交给守卫报错
|
|
79
|
+
return `${value}${dflt}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把一个可能是相对的路径解析到 `cwd` 之内,并拒绝越界与越权扩展名。
|
|
84
|
+
*
|
|
85
|
+
* 防的是:`../` 跳出工作目录、符号链接指向外部、以及从浏览器端点来的任意路径。
|
|
86
|
+
*
|
|
87
|
+
* @param {string} cwd 会话工作目录(绝对)
|
|
88
|
+
* @param {string} target 目标路径(相对 cwd 或绝对)
|
|
89
|
+
* @param {{exts?: string[]}} [opts]
|
|
90
|
+
* @returns {string} 绝对路径
|
|
91
|
+
* @throws {Error} 越界或扩展名不在白名单
|
|
92
|
+
*/
|
|
93
|
+
export function resolveInCwd(cwd, target, opts = {}) {
|
|
94
|
+
const exts = opts.exts || DOC_EXTS
|
|
95
|
+
if (typeof target !== 'string' || !target.trim()) throw new Error('路径不能为空')
|
|
96
|
+
if (target.includes('\0')) throw new Error('路径非法')
|
|
97
|
+
// Windows 的 NTFS 备用数据流(ADS):`notes.md:stream.txt` 的 extname 是 `.txt`,
|
|
98
|
+
// 能骗过扩展名白名单,写进去的内容挂在既有文件的隐藏流里(`readdir` 看不见,
|
|
99
|
+
// 于是出现"文件系统里有、列表里没有"的黑户)。除盘符外 `:` 只可能来自 ADS,一律拒。
|
|
100
|
+
if (process.platform === 'win32' && target.replace(/^[a-zA-Z]:/, '').includes(':')) {
|
|
101
|
+
throw new Error(`路径非法(不能包含 :):${target}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rootReal = realOrSelf(path.resolve(cwd))
|
|
105
|
+
const abs = path.resolve(rootReal, target)
|
|
106
|
+
|
|
107
|
+
// 逐段向上找到第一个真实存在的祖先,再做 realpath,从而识破符号链接逃逸
|
|
108
|
+
let probe = abs
|
|
109
|
+
const tail = []
|
|
110
|
+
for (;;) {
|
|
111
|
+
if (fs.existsSync(probe)) break
|
|
112
|
+
const parent = path.dirname(probe)
|
|
113
|
+
if (parent === probe) break
|
|
114
|
+
tail.unshift(path.basename(probe))
|
|
115
|
+
probe = parent
|
|
116
|
+
}
|
|
117
|
+
const realProbe = realOrSelf(probe)
|
|
118
|
+
const resolved = tail.length ? path.join(realProbe, ...tail) : realProbe
|
|
119
|
+
|
|
120
|
+
const rel = path.relative(rootReal, resolved)
|
|
121
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
122
|
+
throw new Error(`路径越界(必须在会话工作目录内):${target}`)
|
|
123
|
+
}
|
|
124
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
125
|
+
if (!exts.includes(ext)) {
|
|
126
|
+
throw new Error(`不支持的扩展名 ${ext || '(无)'},只允许 ${exts.join(' / ')}`)
|
|
127
|
+
}
|
|
128
|
+
return resolved
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 文档键:同一路径在大小写不敏感的平台上必须落到同一份状态。
|
|
133
|
+
*
|
|
134
|
+
* 只在 Windows 上折成小写——Linux/macOS 上 `Notes.md` 与 `notes.md` 是**两份文件**,
|
|
135
|
+
* 无条件小写会把它们的 state / baseline / 批注串在一起。Windows 侧真正保证一致的是
|
|
136
|
+
* `canonicalCwd` + `realpath` 给出的规范大小写,这里的折写只是兜底。
|
|
137
|
+
*/
|
|
138
|
+
export function docKey(absPath) {
|
|
139
|
+
const abs = path.resolve(absPath)
|
|
140
|
+
return createHash('sha1')
|
|
141
|
+
.update(process.platform === 'win32' ? abs.toLowerCase() : abs)
|
|
142
|
+
.digest('hex')
|
|
143
|
+
.slice(0, 12)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 目录与状态读写 ──────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function fishpaiDir(cwd) {
|
|
149
|
+
return path.join(cwd, '.fishpai')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stateDir(cwd) {
|
|
153
|
+
return path.join(fishpaiDir(cwd), 'state')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function historyDir(cwd, key) {
|
|
157
|
+
return path.join(fishpaiDir(cwd), 'history', key)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function docsDir(cwd) {
|
|
161
|
+
return path.join(fishpaiDir(cwd), 'docs')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 自建 .fishpai/.gitignore 的内容:运行时状态与「自定义主题」都不入库,文档本体在 docs/ 下,由用户自行决定。 */
|
|
165
|
+
const IGNORE_HEADER = '# FishPai 运行时状态与自定义主题;文档本体在 docs/ 下,可自行决定是否入库\n'
|
|
166
|
+
const IGNORE_LINES = ['state/', 'history/', 'theme.json']
|
|
167
|
+
|
|
168
|
+
/** 自建 .fishpai/.gitignore,免得运行时状态弄脏用户仓库;不动用户的 .gitignore。 */
|
|
169
|
+
export function ensureFishpaiLayout(cwd) {
|
|
170
|
+
for (const dir of [fishpaiDir(cwd), stateDir(cwd), docsDir(cwd)]) {
|
|
171
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
172
|
+
}
|
|
173
|
+
const ignore = path.join(fishpaiDir(cwd), '.gitignore')
|
|
174
|
+
if (!fs.existsSync(ignore)) {
|
|
175
|
+
fs.writeFileSync(ignore, `${IGNORE_HEADER}${IGNORE_LINES.join('\n')}\n`, 'utf8')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// 早就建过的目录要补上后来新增的忽略项:只追加缺的行,用户自己写的改动一字不动
|
|
179
|
+
const lines = fs.readFileSync(ignore, 'utf8').split(/\r?\n/)
|
|
180
|
+
const missing = IGNORE_LINES.filter((line) => !lines.includes(line))
|
|
181
|
+
if (missing.length) fs.appendFileSync(ignore, `${missing.join('\n')}\n`, 'utf8')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readJson(file, fallback) {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
187
|
+
} catch {
|
|
188
|
+
return fallback
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeJson(file, value) {
|
|
193
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
194
|
+
// 临时文件 + rename,避免写一半留下坏 JSON
|
|
195
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
196
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8')
|
|
197
|
+
fs.renameSync(tmp, file)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function indexPath(cwd) {
|
|
201
|
+
return path.join(stateDir(cwd), 'index.json')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function emptyIndex() {
|
|
205
|
+
return { version: STATE_VERSION, active: {}, docs: {} }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function readIndex(cwd) {
|
|
209
|
+
const idx = readJson(indexPath(cwd), emptyIndex())
|
|
210
|
+
if (!idx || typeof idx !== 'object') return emptyIndex()
|
|
211
|
+
idx.version = STATE_VERSION
|
|
212
|
+
idx.active = idx.active && typeof idx.active === 'object' ? idx.active : {}
|
|
213
|
+
idx.docs = idx.docs && typeof idx.docs === 'object' ? idx.docs : {}
|
|
214
|
+
return idx
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writeIndex(cwd, idx) {
|
|
218
|
+
writeJson(indexPath(cwd), idx)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function statePathFor(cwd, key) {
|
|
222
|
+
return path.join(stateDir(cwd), `${key}.json`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function defaultState(absPath) {
|
|
226
|
+
return {
|
|
227
|
+
version: STATE_VERSION,
|
|
228
|
+
docPath: absPath,
|
|
229
|
+
revision: 0,
|
|
230
|
+
updatedAt: Date.now(),
|
|
231
|
+
updatedBy: null,
|
|
232
|
+
theme: 'default',
|
|
233
|
+
color: null,
|
|
234
|
+
font: 'sans',
|
|
235
|
+
fontSize: '16px',
|
|
236
|
+
footnotes: true,
|
|
237
|
+
macCodeBlock: true,
|
|
238
|
+
mobile: false,
|
|
239
|
+
baseline: null,
|
|
240
|
+
notes: [],
|
|
241
|
+
history: [],
|
|
242
|
+
openRequests: {},
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function readState(cwd, key) {
|
|
247
|
+
const st = readJson(statePathFor(cwd, key), null)
|
|
248
|
+
if (!st) return null
|
|
249
|
+
st.version = STATE_VERSION
|
|
250
|
+
st.notes = Array.isArray(st.notes) ? st.notes : []
|
|
251
|
+
st.history = Array.isArray(st.history) ? st.history : []
|
|
252
|
+
st.openRequests = st.openRequests && typeof st.openRequests === 'object' ? st.openRequests : {}
|
|
253
|
+
return st
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeState(cwd, key, state) {
|
|
257
|
+
writeJson(statePathFor(cwd, key), state)
|
|
258
|
+
return state
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── 标题与 slug ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/** 取文档标题:第一个 ATX 标题 > 第一行非空文本 > 文件名。 */
|
|
264
|
+
export function docTitle(markdown, absPath) {
|
|
265
|
+
const lines = String(markdown || '').split('\n')
|
|
266
|
+
for (const line of lines) {
|
|
267
|
+
const m = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line)
|
|
268
|
+
if (m) return m[1].trim()
|
|
269
|
+
}
|
|
270
|
+
for (const line of lines) {
|
|
271
|
+
if (line.trim()) return line.trim().slice(0, 60)
|
|
272
|
+
}
|
|
273
|
+
return path.basename(absPath, path.extname(absPath))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function slugify(title) {
|
|
277
|
+
const base = String(title || '')
|
|
278
|
+
// 控制字符(含 NUL)一并去掉:`\s` 不匹配 `\u0000`,漏掉它就会拿一个含 NUL 的路径去建文件,
|
|
279
|
+
// 换来一个 Node 内部报错(`ERR_INVALID_ARG_VALUE`)而不是"新建文档失败"这种能读懂的话。
|
|
280
|
+
.replace(/[\u0000-\u001f\u007f\\/:*?"<>|#]+/g, '')
|
|
281
|
+
.replace(/\s+/g, '-')
|
|
282
|
+
.replace(/-+/g, '-')
|
|
283
|
+
.replace(/^-|-$/g, '')
|
|
284
|
+
.slice(0, 40)
|
|
285
|
+
return base || 'untitled'
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 默认文档路径:`<cwd>/.fishpai/docs/<标题 slug>.md`(重名自动加序号)。 */
|
|
289
|
+
export function defaultDocPath(cwd, markdown) {
|
|
290
|
+
ensureFishpaiLayout(cwd)
|
|
291
|
+
const title = docTitle(markdown, 'untitled')
|
|
292
|
+
const dir = docsDir(cwd)
|
|
293
|
+
let candidate = path.join(dir, `${slugify(title)}.md`)
|
|
294
|
+
let n = 2
|
|
295
|
+
while (fs.existsSync(candidate)) {
|
|
296
|
+
candidate = path.join(dir, `${slugify(title)}-${n}.md`)
|
|
297
|
+
n++
|
|
298
|
+
}
|
|
299
|
+
return candidate
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── 历史 ───────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function pushHistory(cwd, key, state, content, rev, by) {
|
|
305
|
+
const at = Date.now()
|
|
306
|
+
const id = `${String(rev).padStart(4, '0')}-${at.toString(36)}`
|
|
307
|
+
const rel = path.join('.fishpai', 'history', key, `${id}-${by || 'system'}.md`)
|
|
308
|
+
const abs = path.join(cwd, rel)
|
|
309
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
310
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
311
|
+
const entry = { id, rev, at, by: by || 'system', file: rel.replace(/\\/g, '/'), chars: content.length }
|
|
312
|
+
state.history.unshift(entry)
|
|
313
|
+
// 只留最近 50 份,避免无限膨胀
|
|
314
|
+
for (const dropped of state.history.splice(50)) {
|
|
315
|
+
try {
|
|
316
|
+
fs.unlinkSync(path.join(cwd, dropped.file))
|
|
317
|
+
} catch {
|
|
318
|
+
/* 已经不在就算了 */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return entry
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 取回某一版历史内容。
|
|
326
|
+
* @param {string|number} ref 历史条目 id(推荐)或 revision(兼容旧客户端)
|
|
327
|
+
*/
|
|
328
|
+
export function readHistoryEntry(cwd, key, ref) {
|
|
329
|
+
const state = readState(cwd, key)
|
|
330
|
+
if (!state) return null
|
|
331
|
+
const byId = state.history.find((h) => h.id === String(ref))
|
|
332
|
+
const entry = byId || state.history.find((h) => h.rev === Number(ref))
|
|
333
|
+
if (!entry) return null
|
|
334
|
+
const abs = path.join(cwd, entry.file)
|
|
335
|
+
if (!fs.existsSync(abs)) return null
|
|
336
|
+
return { entry, content: fs.readFileSync(abs, 'utf8') }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 把一段**不落盘**的文本存进历史(冲突时保住用户的未保存草稿)。
|
|
341
|
+
*
|
|
342
|
+
* 为什么不走 saveDoc:那会把文档正文替换成草稿,等于用"保命"换了"覆盖 AI 的版本"。
|
|
343
|
+
* 这里只写 history + 列表,正文一个字都不动。
|
|
344
|
+
*/
|
|
345
|
+
export function stash({ cwd, docPath, markdown, by = 'human', label }) {
|
|
346
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
347
|
+
const key = docKey(abs)
|
|
348
|
+
const state = readState(cwd, key)
|
|
349
|
+
if (!state) return null
|
|
350
|
+
const entry = pushHistory(cwd, key, state, String(markdown ?? ''), state.revision, by)
|
|
351
|
+
if (label) entry.label = label
|
|
352
|
+
writeState(cwd, key, state)
|
|
353
|
+
return { entry, revision: state.revision }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function baselineContent(cwd, state) {
|
|
357
|
+
if (!state || !state.baseline) return ''
|
|
358
|
+
const abs = path.join(cwd, state.baseline.file)
|
|
359
|
+
try {
|
|
360
|
+
return fs.readFileSync(abs, 'utf8')
|
|
361
|
+
} catch {
|
|
362
|
+
return ''
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── 文档读写 ───────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/** 打开/新建文档。已存在就不覆盖——把"你没写进去"明确告诉模型。 */
|
|
369
|
+
export function openDoc({ cwd, docPath, markdown, theme, by = 'ai' }) {
|
|
370
|
+
ensureFishpaiLayout(cwd)
|
|
371
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
372
|
+
const key = docKey(abs)
|
|
373
|
+
const exists = fs.existsSync(abs)
|
|
374
|
+
let state = readState(cwd, key)
|
|
375
|
+
|
|
376
|
+
if (!exists) {
|
|
377
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
378
|
+
const content = String(markdown ?? '')
|
|
379
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
380
|
+
state = defaultState(abs)
|
|
381
|
+
state.revision = 1
|
|
382
|
+
state.updatedBy = by
|
|
383
|
+
if (theme) state.theme = theme
|
|
384
|
+
state.baseline = { rev: 1, at: Date.now(), by, file: pushHistory(cwd, key, state, content, 1, by).file }
|
|
385
|
+
state = writeState(cwd, key, state)
|
|
386
|
+
registerDoc(cwd, key, abs, content, state)
|
|
387
|
+
return { key, path: abs, state, markdown: content, created: true, markdownIgnored: false }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!state) {
|
|
391
|
+
// 接管一篇已经存在的文档:**不**编造 baseline。
|
|
392
|
+
// baseline 的语义是"模型上次写入的版本",模型还没写过它就不存在;
|
|
393
|
+
// 若这里拿当前内容当 baseline,模型会以为"人什么都没改",把最贵的那个信号抹掉。
|
|
394
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
395
|
+
state = defaultState(abs)
|
|
396
|
+
state.revision = 1
|
|
397
|
+
state.updatedBy = 'human'
|
|
398
|
+
if (theme) state.theme = theme
|
|
399
|
+
state.baseline = null
|
|
400
|
+
state = writeState(cwd, key, state)
|
|
401
|
+
registerDoc(cwd, key, abs, content, state)
|
|
402
|
+
return { key, path: abs, state, markdown: content, created: false, markdownIgnored: markdown !== undefined, adopted: true }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
406
|
+
if (theme && state.theme !== theme) state = writeState(cwd, key, { ...state, theme })
|
|
407
|
+
registerDoc(cwd, key, abs, content, state)
|
|
408
|
+
return { key, path: abs, state, markdown: content, created: false, markdownIgnored: markdown !== undefined }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function registerDoc(cwd, key, abs, markdown, state) {
|
|
412
|
+
const idx = readIndex(cwd)
|
|
413
|
+
idx.docs[key] = {
|
|
414
|
+
key,
|
|
415
|
+
path: abs,
|
|
416
|
+
title: docTitle(markdown, abs),
|
|
417
|
+
revision: state.revision,
|
|
418
|
+
updatedAt: state.updatedAt,
|
|
419
|
+
}
|
|
420
|
+
writeIndex(cwd, idx)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 人自己起一篇空白文档(面板上的「新建空白文档」):落在 `<cwd>/.fishpai/docs/` 下,
|
|
425
|
+
* 与 `fishpai_open` 建的是同一类普通 .md,区别只在 `updatedBy: 'human'`。
|
|
426
|
+
*
|
|
427
|
+
* 故意**先落盘、再接管**:这样走的是 `openDoc` 的"接管既有文档"分支,baseline 保持 `null`。
|
|
428
|
+
* baseline 的语义是"模型上次写入的版本"——模型还没写过就不该编造一个;
|
|
429
|
+
* 若这里直接 `openDoc({created})`,baseline 会被写成 `by: 'human'`,模型下一次 read 会看到
|
|
430
|
+
* "你上次写入(by human)"这种自相矛盾的话。
|
|
431
|
+
*/
|
|
432
|
+
export function createDoc({ cwd, title = '未命名' }) {
|
|
433
|
+
ensureFishpaiLayout(cwd)
|
|
434
|
+
const clean = String(title || '').trim() || '未命名'
|
|
435
|
+
const markdown = `# ${clean}\n`
|
|
436
|
+
const abs = defaultDocPath(cwd, markdown)
|
|
437
|
+
fs.writeFileSync(abs, markdown, 'utf8')
|
|
438
|
+
const opened = openDoc({ cwd, docPath: abs, by: 'human' })
|
|
439
|
+
return { ...opened, markdown }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function setActive(cwd, sessionId, key) {
|
|
443
|
+
const idx = readIndex(cwd)
|
|
444
|
+
idx.active[String(sessionId)] = key
|
|
445
|
+
writeIndex(cwd, idx)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function activeKey(cwd, sessionId) {
|
|
449
|
+
const idx = readIndex(cwd)
|
|
450
|
+
return idx.active[String(sessionId)] || null
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function listDocs(cwd) {
|
|
454
|
+
const idx = readIndex(cwd)
|
|
455
|
+
return Object.values(idx.docs).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 写文档(人改或 AI 改都走这里)。
|
|
460
|
+
*
|
|
461
|
+
* `baseRevision` 是**必填**的:调用方必须说清"我读的是哪一版"。
|
|
462
|
+
* **缺失不等于强制覆盖**——这正是"绝不静默覆盖人的手改"要挡的那条路。
|
|
463
|
+
* 若写成"有版本才比对",不传 `baseRevision` 就能连续覆盖人的手改:
|
|
464
|
+
* `Number(undefined)` 是 `NaN`,连"版本号非法"都识别不出来。
|
|
465
|
+
* 所以这里把"拿不出合法版本号"与"版本不匹配"合并成同一个结果:冲突。
|
|
466
|
+
*
|
|
467
|
+
* @param {object} p
|
|
468
|
+
* @param {number} p.baseRevision 调用方读到的 revision;缺失或不匹配都拒绝(防覆盖)
|
|
469
|
+
* @param {'human'|'ai'} p.by
|
|
470
|
+
* @returns {{ok: true, state: object} | {ok: false, conflict: true, revision: number, markdown: string}}
|
|
471
|
+
*/
|
|
472
|
+
export function saveDoc({ cwd, docPath, markdown, baseRevision, by = 'human', meta = {} }) {
|
|
473
|
+
ensureFishpaiLayout(cwd)
|
|
474
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
475
|
+
const key = docKey(abs)
|
|
476
|
+
const prevContent = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''
|
|
477
|
+
let state = readState(cwd, key)
|
|
478
|
+
if (!state) {
|
|
479
|
+
state = defaultState(abs)
|
|
480
|
+
state.revision = 1
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const hasBase =
|
|
484
|
+
baseRevision !== undefined && baseRevision !== null && Number.isFinite(Number(baseRevision))
|
|
485
|
+
if (!hasBase || Number(baseRevision) !== state.revision) {
|
|
486
|
+
return { ok: false, conflict: true, revision: state.revision, markdown: prevContent, path: abs, key }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 先留旧内容,再覆盖:任何一版都能回滚
|
|
490
|
+
if (prevContent && prevContent !== markdown) {
|
|
491
|
+
pushHistory(cwd, key, state, prevContent, state.revision, state.updatedBy || 'system')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
495
|
+
fs.writeFileSync(abs, markdown, 'utf8')
|
|
496
|
+
|
|
497
|
+
state.revision += 1
|
|
498
|
+
state.updatedAt = Date.now()
|
|
499
|
+
state.updatedBy = by
|
|
500
|
+
applyMeta(state, meta)
|
|
501
|
+
|
|
502
|
+
if (by === 'ai') {
|
|
503
|
+
// AI 写入即刷新 baseline:下一次 read 看到的就是"人在这之后改了什么"
|
|
504
|
+
state.baseline = { rev: state.revision, at: Date.now(), by, file: pushHistory(cwd, key, state, markdown, state.revision, by).file }
|
|
505
|
+
}
|
|
506
|
+
// 人的写入**不**动 baseline(没有 baseline 时也不凭空造一个)——那正是"人改了什么"的来源
|
|
507
|
+
|
|
508
|
+
state = writeState(cwd, key, state)
|
|
509
|
+
registerDoc(cwd, key, abs, markdown, state)
|
|
510
|
+
return { ok: true, state, path: abs, key }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 重新对齐 baseline 到当前内容(人在面板上点"以当前为准"时用)。 */
|
|
514
|
+
export function rebase({ cwd, docPath, by = 'human' }) {
|
|
515
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
516
|
+
const key = docKey(abs)
|
|
517
|
+
const state = readState(cwd, key)
|
|
518
|
+
if (!state) return null
|
|
519
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
520
|
+
const entry = pushHistory(cwd, key, state, content, state.revision, by)
|
|
521
|
+
const next = { ...state, baseline: { rev: state.revision, at: Date.now(), by, file: entry.file } }
|
|
522
|
+
return writeState(cwd, key, next)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 会被写进文档状态的设置项。
|
|
527
|
+
*
|
|
528
|
+
* 其中 `color` 与 `fontSize` 是**仅有的两个原样插值进 HTML** 的字段
|
|
529
|
+
* (`color` 替换主题里的 `{{PRIMARY}}` 占位符,`fontSize` 拼进 `font-size: …;`),
|
|
530
|
+
* 两者都在这里过一道形态校验;其余字段只当开关/枚举用,不参与拼串。
|
|
531
|
+
*/
|
|
532
|
+
const META_FIELDS = ['theme', 'color', 'font', 'fontSize', 'footnotes', 'macCodeBlock', 'mobile']
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 主题色只认 `#` + 3~8 位十六进制。
|
|
536
|
+
*
|
|
537
|
+
* 值里带一个引号就能闭合 `style="…"` 属性、把后面的字节变成任意属性(渲染层只对 wrapper
|
|
538
|
+
* 那一个槽位做了引号中和)。不合法的值按老规矩**静默忽略**:面板的色板本来只会给合法值,
|
|
539
|
+
* 走到这里说明请求是手写的,为它让整次「保存设置」失败得不偿失。
|
|
540
|
+
*/
|
|
541
|
+
export function isThemeColor(value) {
|
|
542
|
+
return typeof value === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(value)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** 把 meta 里认识的字段写进状态对象(就地改),返回它。`saveDoc` 与 `updateMeta` 共用一份口径。 */
|
|
546
|
+
function applyMeta(target, meta) {
|
|
547
|
+
for (const field of META_FIELDS) {
|
|
548
|
+
if (meta[field] === undefined) continue
|
|
549
|
+
// null 是"清掉主题色"的合法写法(面板上再点一次同一个色块就是清掉)
|
|
550
|
+
if (field === 'color' && meta[field] !== null && !isThemeColor(meta[field])) continue
|
|
551
|
+
// 字号与主题色同一条规矩:它是原样拼进 style 的第二个字段,形态不对就当没写
|
|
552
|
+
if (field === 'fontSize' && !isFontSize(meta[field])) continue
|
|
553
|
+
target[field] = meta[field]
|
|
554
|
+
}
|
|
555
|
+
return target
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function updateMeta({ cwd, docPath, meta }) {
|
|
559
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
560
|
+
const key = docKey(abs)
|
|
561
|
+
const state = readState(cwd, key)
|
|
562
|
+
if (!state) return null
|
|
563
|
+
const next = applyMeta({ ...state }, meta)
|
|
564
|
+
return writeState(cwd, key, next)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── 图片资产(面板上粘贴/拖进来的图)───────────────────────────
|
|
568
|
+
|
|
569
|
+
const ASSET_MIME_EXT = {
|
|
570
|
+
'image/png': '.png',
|
|
571
|
+
'image/jpeg': '.jpg',
|
|
572
|
+
'image/jpg': '.jpg',
|
|
573
|
+
'image/gif': '.gif',
|
|
574
|
+
'image/webp': '.webp',
|
|
575
|
+
'image/bmp': '.bmp',
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* 允许**上传**(存成资产)的扩展名。
|
|
580
|
+
*
|
|
581
|
+
* 比 `IMAGE_EXTS` 少一个 `.svg`:`.svg` 留在 `IMAGE_EXTS` 里是为了能解析正文里
|
|
582
|
+
* 已经引用的图(宿主只读它、不改它),但上传入口只收栅格图——
|
|
583
|
+
* 少了这一行,`name=x.svg` 会绕过 MIME 白名单存进来,与"只支持 PNG / JPEG / GIF / WebP / BMP"的提示自相矛盾。
|
|
584
|
+
*/
|
|
585
|
+
const UPLOAD_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
|
586
|
+
|
|
587
|
+
function stamp(at = new Date()) {
|
|
588
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
589
|
+
return `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* 从文件名里取一段能认人的词。剪贴板里的图一律叫 `image.png` / `blob`,
|
|
594
|
+
* 拿它当名字等于没有名字,所以这类通用名换成 `paste`。
|
|
595
|
+
*
|
|
596
|
+
* 扩展名按**名字自己的**扩展名切(不是按最终落盘的那个):否则 `x.svg` 被改存成 `.png` 时,
|
|
597
|
+
* `path.basename('x.svg', '.png')` 会原样返回 `x.svg`,落盘变成 `…-x.svg.png`。
|
|
598
|
+
*/
|
|
599
|
+
function assetStem(name) {
|
|
600
|
+
const raw = String(name || '')
|
|
601
|
+
const base = path
|
|
602
|
+
.basename(raw, path.extname(raw))
|
|
603
|
+
.replace(/[\\/:*?"<>|#\s\u0000-\u001f\u007f]+/g, '-')
|
|
604
|
+
.replace(/-+/g, '-')
|
|
605
|
+
.replace(/^-|-$/g, '')
|
|
606
|
+
.slice(0, 24)
|
|
607
|
+
if (!base || /^(image|blob|clipboard|untitled|screenshot|截图)$/i.test(base)) return 'paste'
|
|
608
|
+
return base
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 存一张面板上粘贴(或拖入)的图片。
|
|
613
|
+
*
|
|
614
|
+
* 落点是**文档同级的 `assets/`**:这样 Markdown 里写的是 `assets/xxx.png` 这种相对路径,
|
|
615
|
+
* 文档搬走、进 Git、发给别人,图都跟着走(微信那边也能被内嵌成 base64 带过去)。
|
|
616
|
+
*
|
|
617
|
+
* @param {{cwd: string, docPath: string, name?: string, mime?: string, data?: string, maxBytes?: number}} p
|
|
618
|
+
* `data` 是 base64(不含 `data:` 前缀)
|
|
619
|
+
* @returns {{src: string, path: string, bytes: number}} `src` 是相对**文档目录**的写法
|
|
620
|
+
*/
|
|
621
|
+
export function saveAsset({ cwd, docPath, name, mime, data, maxBytes = MAX_ASSET_BYTES }) {
|
|
622
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
623
|
+
const buf = Buffer.from(String(data || ''), 'base64')
|
|
624
|
+
if (!buf.length) throw new Error('图片内容为空')
|
|
625
|
+
if (buf.length > maxBytes) {
|
|
626
|
+
throw new Error(`图片 ${(buf.length / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(maxBytes / 1024 / 1024)}MB 上限`)
|
|
627
|
+
}
|
|
628
|
+
const ext =
|
|
629
|
+
ASSET_MIME_EXT[String(mime || '').toLowerCase()] ||
|
|
630
|
+
UPLOAD_EXTS.find((e) => e === path.extname(String(name || '')).toLowerCase())
|
|
631
|
+
if (!ext) throw new Error('只支持 PNG / JPEG / GIF / WebP / BMP 图片')
|
|
632
|
+
|
|
633
|
+
const dir = path.join(path.dirname(abs), ASSET_DIR)
|
|
634
|
+
const stem = assetStem(name)
|
|
635
|
+
const base = `${stamp()}-${stem}`
|
|
636
|
+
let target = path.join(dir, `${base}${ext}`)
|
|
637
|
+
for (let n = 2; fs.existsSync(target); n++) target = path.join(dir, `${base}-${n}${ext}`)
|
|
638
|
+
|
|
639
|
+
// 守卫必须在**落盘之前**:`assets/` 若是指向工作目录之外的目录 junction,
|
|
640
|
+
// 事后才报错就晚了——字节已经写到了外面,接口回 400 也收不回来。
|
|
641
|
+
// resolveInCwd 对"还不存在的文件"同样有效(逐段向上找真实祖先再 realpath),所以先判后写可行。
|
|
642
|
+
const safe = resolveInCwd(cwd, target, { exts: IMAGE_EXTS })
|
|
643
|
+
fs.mkdirSync(path.dirname(safe), { recursive: true })
|
|
644
|
+
fs.writeFileSync(safe, buf)
|
|
645
|
+
return { src: path.relative(path.dirname(abs), safe).split(path.sep).join('/'), path: safe, bytes: buf.length }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── 批注 ───────────────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
export function addNote({ cwd, docPath, note }) {
|
|
651
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
652
|
+
const key = docKey(abs)
|
|
653
|
+
const state = readState(cwd, key)
|
|
654
|
+
if (!state) return null
|
|
655
|
+
const entry = { id: randomUUID().slice(0, 8), author: 'human', at: Date.now(), resolved: false, ...note }
|
|
656
|
+
const next = { ...state, notes: [...state.notes, entry] }
|
|
657
|
+
writeState(cwd, key, next)
|
|
658
|
+
return entry
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function updateNote({ cwd, docPath, id, patch }) {
|
|
662
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
663
|
+
const key = docKey(abs)
|
|
664
|
+
const state = readState(cwd, key)
|
|
665
|
+
if (!state) return null
|
|
666
|
+
let found = null
|
|
667
|
+
const notes = state.notes.map((n) => {
|
|
668
|
+
if (n.id !== id) return n
|
|
669
|
+
found = { ...n, ...patch, id: n.id }
|
|
670
|
+
return found
|
|
671
|
+
})
|
|
672
|
+
if (!found) return null
|
|
673
|
+
writeState(cwd, key, { ...state, notes })
|
|
674
|
+
return found
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function removeNote({ cwd, docPath, id }) {
|
|
678
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
679
|
+
const key = docKey(abs)
|
|
680
|
+
const state = readState(cwd, key)
|
|
681
|
+
if (!state) return null
|
|
682
|
+
const before = state.notes.length
|
|
683
|
+
const notes = state.notes.filter((n) => n.id !== id)
|
|
684
|
+
writeState(cwd, key, { ...state, notes })
|
|
685
|
+
return before !== notes.length
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── 打开请求(宿主 → 客户端的唯一推送手段)─────────────────────
|
|
689
|
+
|
|
690
|
+
const OPEN_TTL_MS = 60_000
|
|
691
|
+
|
|
692
|
+
export function requestOpen({ cwd, docPath, sessionId }) {
|
|
693
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
694
|
+
const key = docKey(abs)
|
|
695
|
+
const state = readState(cwd, key)
|
|
696
|
+
if (!state) return null
|
|
697
|
+
const openRequests = { ...state.openRequests, [String(sessionId)]: Date.now() }
|
|
698
|
+
return writeState(cwd, key, { ...state, openRequests })
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function consumeOpenRequest({ cwd, docPath, sessionId }) {
|
|
702
|
+
const abs = resolveInCwd(cwd, docPath)
|
|
703
|
+
const key = docKey(abs)
|
|
704
|
+
const state = readState(cwd, key)
|
|
705
|
+
if (!state) return false
|
|
706
|
+
const at = state.openRequests[String(sessionId)]
|
|
707
|
+
if (!at) return false
|
|
708
|
+
const openRequests = { ...state.openRequests }
|
|
709
|
+
delete openRequests[String(sessionId)]
|
|
710
|
+
writeState(cwd, key, { ...state, openRequests })
|
|
711
|
+
return true
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 未过期且属于该会话的打开请求。 */
|
|
715
|
+
export function pendingOpenRequest(state, sessionId) {
|
|
716
|
+
const at = state && state.openRequests ? state.openRequests[String(sessionId)] : null
|
|
717
|
+
if (!at) return null
|
|
718
|
+
return Date.now() - at < OPEN_TTL_MS ? at : null
|
|
719
|
+
}
|