红笔批阅:在 Claude 的回答上逐段批「采纳 / 不要 / 啥意思 / 批注」,一键拼成「原文——批注」发出;长回答自动按规矩批一遍,问过的词记进看不懂词典

一个 Claude Code 的 mod:像改稿子一样批 Claude 的回答,不用再复制原文、手打批注。
<img src="docs/screenshot.png" width="760" alt="红笔批阅在桌面 App 里的样子"><br> <sub>① 点「啥意思」,解释显示在这段下面 ② 点段落末尾的「✎」展开工具栏,批过的段下面标着「✎ 采纳」 ③ 输入框上方的批阅篮,一键发出</sub>
方案 1.1 加一句……——不要
方案二、预算……——不用写这么细
其余都采纳
<img src="docs/auto-review.png" alt="自动红笔:长回答下面列出挑出的问题,可以逐条照改">
/redpen,打开面板。里面有批阅篮、今天的使用次数、两个开关(段落按钮、自动红笔)和看不懂词典,词典里的词可以逐个删掉。第 1 步:确认终端里有 Claude Code。 安装命令要在终端(Mac 的「终端」App)里运行。先输入:
claude --version
command not found:说明你只在用桌面 App,还没装命令行版。先装一次,装完关掉终端重新打开: curl -fsSL https://claude.ai/install.sh | bash
claude update。第 2 步:安装红笔批阅。
claude plugin install red-pen --marketplace sky-nju/claude-mods
看到 ✔ Successfully installed plugin: red-pen 就装好了。第一次装时,如果 Mac 弹窗让你安装「命令行开发者工具」,点「安装」,装完再运行一次上面的命令。
第 3 步:新开一个会话试一下。 终端和桌面 App 都行,已经开着的会话要新开才会加载。随便问一个需要长回答的问题,鼠标停在回答的某一段上,段落末尾出现「✎」就说明装好了。
我改进后会升版本号。想用新版,在终端依次运行:
claude plugin marketplace update sky-claude-mods
claude plugin update red-pen@sky-claude-mods
然后新开一个会话。
/redpen 面板里,把「段落按钮」或「自动红笔」切到「关」。claude plugin disable red-pen@sky-claude-mods,想恢复用 enable。claude plugin uninstall red-pen;想把插件来源也一起删掉,再运行 claude plugin marketplace remove sky-claude-mods。自动红笔默认用内置的 6 条「说人话」规矩。想用自己的,就新建一个文件 .claude/red-pen-rules.md:
~/.claude/red-pen-rules.md),对所有项目生效。写法参考 red-pen-rules.example.md:一行一条,写清楚什么算问题、什么不算。改完下次新开会话生效。
插件只和你正在用的 Claude 打交道,不会把内容发到别的地方。
| 什么时候 | 发什么 | 发给谁 |
|---|---|---|
| 点「啥意思」 | 这段文字,加上当前会话的全部对话 | 当前会话用的模型。会话越长,每问一次读的内容越多,所以只在你点的时候才问 |
| 自动红笔 | 这条回答的正文(不含代码块)、你的规矩、最近 30 条「这条不算」的例子 | haiku 小模型,不带对话历史 |
| 每个新会话开始时 | 看不懂词典里最近的 30 个词,和最常被挑出的两类问题 | 写进 Claude 的系统提示,让它少用这些词 |
批阅篮、看不懂词典和使用次数都存在你本机的 Claude Code 里。如果你的环境里用不了 haiku,自动红笔会安静地不出现,其他功能照常能用。
claude plugin validate red-pen
claude plugin test red-pen
每次推送前把 red-pen/.claude-plugin/plugin.json 里的 version 加一,否则已经装过的同事更新不到。
hooks/register.tsx 688 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PenBasketItem, PenBlock, PenDayStats, PenDictEntry, PenExplain, PenMark, PenReview, PenSettings } from '../types'
5import {
6 DEFAULT_RULES,
7 EXPLAIN_PROMPT,
8 REVIEW_SYSTEM,
9 composeMessage,
10 dayOf,
11 emptyDay,
12 explainMarkdown,
13 hash,
14 matchesAnswer,
15 mergeDict,
16 normalize,
17 parseFindings,
18 parseTerms,
19 plainQuote,
20 proseOf,
21 readerSection,
22 reviewPrompt,
23 splitUnits,
24} from './text'
25import type { Unit } from './text'
26
27const PANE = 'red-pen'
28const REVIEW_MODEL = 'haiku'
29// 规矩文件:先找当前目录的,再找自己 home 下的;都没有就用内置的
30const RULES_FILE = '.claude/red-pen-rules.md'
31
32// 「啥意思」问了这么久还没回来,就当没问到(比如问的过程中插件被重新加载了)
33const ASK_TIMEOUT_MS = 60_000
34
35const EMPTY_BLOCK: PenBlock = { marks: {}, explains: {}, editing: '' }
36// 门槛按去掉代码块后的正文字数算
37const DEFAULT_SETTINGS: PenSettings = { enabled: true, autoReview: true, minChars: 800 }
38
39const blocks = atom({ plugin: 'red-pen', key: 'blocks' } as const, EMPTY_BLOCK)
40const basket = atom({ plugin: 'red-pen', key: 'basket' } as const, [] as PenBasketItem[])
41const allAccept = atom({ plugin: 'red-pen', key: 'allAccept' } as const, false)
42const reviews = atom({ plugin: 'red-pen', key: 'reviews' } as const, [] as PenReview[])
43const settings = atom({ plugin: 'red-pen', key: 'settings' } as const, DEFAULT_SETTINGS)
44const today = atom({ plugin: 'red-pen', key: 'today' } as const, emptyDay(''))
45const dict = atom({ plugin: 'red-pen', key: 'dict' } as const, [] as PenDictEntry[])
46const section = atom({ plugin: 'red-pen', key: 'section' } as const, '')
47const rulesFrom = atom({ plugin: 'red-pen', key: 'rulesFrom' } as const, '内置 6 条')
48
49type Counter = Exclude<keyof PenDayStats, 'date' | 'byRule'>
50
51// 规矩全文只给红笔批改用,不进界面,放模块变量;重新加载时 session.start 会再读一次
52let rules = DEFAULT_RULES
53// 会话所在目录:/clear 之后补读规矩文件要用
54let sessionCwd = ''
55
56async function stored<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
57 const value = await $.store.get(key).catch(() => undefined)
58 return value === undefined ? fallback : (value as T)
59}
60
61// 记一次使用:当天的数进会话状态(面板读),同时存 30 天到 $.store。
62// 以存档为准接着数:/clear 清掉会话状态、或者另一个会话也在数,都不会互相覆盖
63async function count($: EngineInterface, field: Counter, n = 1, rule?: string): Promise<void> {
64 const date = dayOf(await $.clock.now())
65 const days = await stored<PenDayStats[]>($, 'days', [])
66 const saved = days.find(d => d.date === date)
67 const day = await update($, today, old => {
68 const base = saved ?? (old.date === date ? old : emptyDay(date))
69 const byRule = rule === undefined ? base.byRule : { ...base.byRule, [rule]: (base.byRule[rule] ?? 0) + n }
70 return { ...base, [field]: base[field] + n, byRule }
71 })
72 await $.store.set('days', [day, ...days.filter(d => d.date !== day.date)].slice(0, 30))
73}
74
75// 词典的增删都在最新存档上做,两个会话同时开着也不会把对方加的词冲掉
76async function changeDict(
77 $: EngineInterface,
78 change: (old: PenDictEntry[]) => PenDictEntry[],
79): Promise<void> {
80 const next = change(await stored<PenDictEntry[]>($, 'dict', []))
81 await $.store.set('dict', next)
82 await update($, dict, () => next)
83}
84
85async function loadRules($: EngineInterface, cwd: string): Promise<void> {
86 const home = await $.env.get('HOME')
87 const paths = [`${cwd}/${RULES_FILE}`, home === undefined ? '' : `${home}/${RULES_FILE}`]
88 for (const path of paths) {
89 if (path === '') continue
90 const text = await $.fs.read(path).catch(() => undefined)
91 if (typeof text === 'string' && text.trim() !== '') {
92 rules = text.trim()
93 await update($, rulesFrom, () => path)
94 return
95 }
96 }
97 rules = DEFAULT_RULES
98 await update($, rulesFrom, () => '内置 6 条')
99}
100
101// 会话开始(含 /clear、/resume):读回设置、词典、统计,算好写进系统提示的那一段
102async function restore($: EngineInterface, cwd: string): Promise<void> {
103 const saved = await stored<Partial<PenSettings>>($, 'settings', {})
104 await update($, settings, () => ({ ...DEFAULT_SETTINGS, ...switches(saved) }))
105 const words = await stored<PenDictEntry[]>($, 'dict', [])
106 await update($, dict, () => words)
107 const days = await stored<PenDayStats[]>($, 'days', [])
108 const date = dayOf(await $.clock.now())
109 await update($, today, () => days.find(d => d.date === date) ?? emptyDay(date))
110 await update($, section, () => readerSection(words, days.slice(0, 7)))
111 await loadRules($, cwd)
112}
113
114// 存档里只记两个开关;门槛跟着插件版本走,升级后改了默认值也能生效
115function switches(saved: Partial<PenSettings> | undefined): Partial<PenSettings> {
116 if (saved === undefined) return {}
117 const picked: Partial<PenSettings> = {}
118 if (typeof saved.enabled === 'boolean') picked.enabled = saved.enabled
119 if (typeof saved.autoReview === 'boolean') picked.autoReview = saved.autoReview
120
121 return picked
122}
123
124// 在最新存档上改开关,另一个会话改过的那个开关不会被这边改回去
125async function saveSettings($: EngineInterface, change: (old: PenSettings) => PenSettings): Promise<void> {
126 const latest = { ...DEFAULT_SETTINGS, ...switches(await stored<Partial<PenSettings>>($, 'settings', {})) }
127 const next = change(latest)
128 await $.store.set('settings', switches(next))
129 await update($, settings, () => next)
130}
131
132// 给一段定下批阅结论(null 是撤回),批阅篮跟着改
133async function setMark(
134 $: EngineInterface,
135 requestId: string,
136 unit: Unit,
137 mark: PenMark | null,
138): Promise<void> {
139 await update($, memberOf(blocks, { requestId }), block => {
140 const marks = { ...block.marks }
141 if (mark === null) delete marks[unit.id]
142 else marks[unit.id] = mark
143 return { ...block, marks, editing: '', open: '' }
144 })
145
146 const key = `${requestId}:${unit.id}`
147 const at = await $.clock.now()
148 await update($, basket, list => {
149 const rest = list.filter(item => item.key !== key)
150 if (mark === null) return rest
151 const old = list.find(item => item.key === key)
152 const item: PenBasketItem = {
153 key,
154 requestId,
155 unitId: unit.id,
156 quote: plainQuote(unit.text),
157 verdict: mark.verdict,
158 note: mark.note,
159 at: old?.at ?? at,
160 }
161 return [...rest, item]
162 })
163 if (mark !== null) await count($, 'marks')
164}
165
166async function toggleVerdict($: EngineInterface, requestId: string, unit: Unit, verdict: 'ok' | 'no') {
167 const block = await read($, memberOf(blocks, { requestId }))
168 const isSame = block.marks[unit.id]?.verdict === verdict
169 await setMark($, requestId, unit, isSame ? null : { verdict, note: '' })
170}
171
172async function saveNote($: EngineInterface, requestId: string, unit: Unit, value: string) {
173 const note = value.trim()
174 await setMark($, requestId, unit, note === '' ? null : { verdict: 'note', note })
175}
176
177async function clearBasket($: EngineInterface): Promise<void> {
178 const items = await read($, basket)
179 for (const requestId of new Set(items.map(item => item.requestId))) {
180 await update($, memberOf(blocks, { requestId }), block => ({ ...block, marks: {}, editing: '' }))
181 }
182 await update($, basket, () => [])
183 await update($, allAccept, () => false)
184}
185
186// 以你自己的名义发出一轮;被拒绝或被丢弃都算没发出去
187async function submitted($: EngineInterface, text: string): Promise<boolean> {
188 const result = await $.prompt.submit({ text, asUser: true }).catch(() => undefined)
189
190 return result !== undefined && result.drop === undefined
191}
192
193// 发不出去时,把这批条目和段落上的标记放回去
194async function putBack($: EngineInterface, items: readonly PenBasketItem[]): Promise<void> {
195 for (const item of items) {
196 await update($, memberOf(blocks, { requestId: item.requestId }), block => ({
197 ...block,
198 marks: { ...block.marks, [item.unitId]: { verdict: item.verdict, note: item.note } },
199 }))
200 }
201 await update($, basket, list => [...items, ...list.filter(one => !items.some(item => item.key === one.key))])
202}
203
204// 拼成「原文……——批注」,以你自己的名义发出一轮。
205// 先把这批条目拿出批阅篮再发:Claude 正在回答时消息要排队,这期间再点不会重复发,新批的段也留在篮子里
206async function send($: EngineInterface): Promise<void> {
207 const items = await read($, basket)
208 if (items.length === 0) return
209 const text = composeMessage(items, await read($, allAccept))
210 await clearBasket($)
211 await $.ui.close({ id: PANE }).catch(() => undefined)
212 $.ui.toast(`已发出 ${items.length} 条批注;Claude 正在回答的话,会等这轮答完再处理`)
213 const sentOk = await submitted($, text)
214 if (!sentOk) {
215 await putBack($, items)
216 $.ui.toast('批注没发出去,已放回批阅篮')
217 return
218 }
219 await count($, 'sent')
220}
221
222// 「啥意思」:带着整段对话旁路问一句,答案只画在这段下面,不进主对话;问到的词进词典
223async function ask($: EngineInterface, requestId: string, unit: Unit): Promise<void> {
224 const member = memberOf(blocks, { requestId })
225 const setExplain = (explain: PenExplain | null) =>
226 update($, member, block => {
227 const explains = { ...block.explains }
228 if (explain === null) delete explains[unit.id]
229 else explains[unit.id] = explain
230 return { ...block, explains, open: '' }
231 })
232
233 // 已经有解释(或正在问)时再点就是收起;正在问时收起,问回来的答案也不再显示
234 const current = (await read($, member)).explains[unit.id]
235 if (current !== undefined) {
236 await setExplain(null)
237 return
238 }
239
240 const ticket = await $.clock.now()
241 await setExplain({ status: 'asking', text: '', ticket })
242 const reply = await $.model.fork({ prompt: EXPLAIN_PROMPT + unit.text }).catch(() => undefined)
243 const still = (await read($, member)).explains[unit.id]
244 if (still?.status !== 'asking' || still.ticket !== ticket) return
245
246 if (reply === undefined || !reply.isAnswered) {
247 const why =
248 reply === undefined
249 ? '没问到,可以再点一次「啥意思」试试'
250 : reply.reason === 'nothing-to-fork'
251 ? '这个会话还没有可以旁路提问的内容'
252 : `没问到(${reply.reason})`
253 await setExplain({ status: 'failed', text: why })
254 return
255 }
256
257 await setExplain({ status: 'done', text: explainMarkdown(reply.text) })
258 const fresh = parseTerms(reply.text, await $.clock.now())
259 if (fresh.length > 0) await changeDict($, old => mergeDict(old, fresh))
260 await count($, 'explains')
261}
262
263async function removeWord($: EngineInterface, term: string): Promise<void> {
264 await changeDict($, old => old.filter(entry => entry.term !== term))
265}
266
267// 一轮结束后,长回答用小模型按规矩批一遍。只批正文:代码块不算字数、也不发给小模型;
268// 正文不到门槛、或者一半以上是代码的回答不批
269async function reviewAnswer($: EngineInterface, answer: string): Promise<void> {
270 const conf = await read($, settings)
271 const prose = proseOf(answer)
272 if (!conf.autoReview || prose.length < conf.minChars || prose.length < answer.length / 2) return
273
274 const id = hash(answer)
275 const at = await $.clock.now()
276 const head = plainQuote(answer, 20)
277 const fresh: PenReview = { id, head, norm: normalize(answer), status: 'checking', findings: [], at }
278 await update($, reviews, list => [fresh, ...list.filter(review => review.id !== id)].slice(0, 20))
279
280 const dismissed = await stored<string[]>($, 'dismissed', [])
281 // 小模型调不到(比如接了别的模型网关、没有 haiku)时调用会直接报错:当作没批,不让「正在批」一直挂着
282 const reply = await $.model
283 .complete({
284 model: REVIEW_MODEL,
285 system: REVIEW_SYSTEM,
286 prompt: [{ text: `规矩:\n${rules}`, cache: true }, { text: reviewPrompt(prose, dismissed) }],
287 maxTokens: 800,
288 effort: 'low',
289 timeoutMs: 30_000,
290 })
291 .catch(() => undefined)
292 const isAnswered = reply !== undefined && reply.isAnswered
293 const findings = isAnswered ? parseFindings(reply.text, prose) : []
294 const status: PenReview['status'] = isAnswered ? 'done' : 'failed'
295 await update($, reviews, list =>
296 list.map(review => (review.id === id ? { ...review, status, findings } : review)),
297 )
298 await count($, 'reviews')
299 for (const finding of findings) await count($, 'findings', 1, finding.rule)
300}
301
302async function applyFindings($: EngineInterface, reviewId: string, indexes: readonly number[]) {
303 const review = (await read($, reviews)).find(one => one.id === reviewId)
304 if (review === undefined) return
305 const picked = indexes.filter(i => review.findings[i]?.state === 'open')
306 if (picked.length === 0) return
307
308 const lines = picked.map(i => {
309 const finding = review.findings[i]
310 return finding === undefined ? '' : `- 「${finding.quote}」:${finding.rule},${finding.fix}`
311 })
312 const which = review.head === undefined || review.head === '' ? '你之前那条回答' : `你之前那条回答(开头是「${review.head}」)`
313
314 // 先标成已改再发:Claude 正在回答时消息要排队,这期间再点不会重复发
315 const mark = (state: 'open' | 'applied') =>
316 update($, reviews, list =>
317 list.map(one =>
318 one.id !== reviewId
319 ? one
320 : { ...one, findings: one.findings.map((f, i) => (picked.includes(i) ? { ...f, state } : f)) },
321 ),
322 )
323 await mark('applied')
324 if (!(await submitted($, [`按红笔改一下${which}:`, ...lines].join('\n')))) {
325 await mark('open')
326 $.ui.toast('修改要求没发出去,可以再点一次「照改」')
327 return
328 }
329 await count($, 'applied', picked.length)
330}
331
332// 「这条不算」:记下来,以后批改时把同类例子告诉小模型
333async function dismissFinding($: EngineInterface, reviewId: string, index: number) {
334 const review = (await read($, reviews)).find(one => one.id === reviewId)
335 const finding = review?.findings[index]
336 if (finding === undefined || finding.state !== 'open') return
337
338 await update($, reviews, list =>
339 list.map(one =>
340 one.id !== reviewId
341 ? one
342 : { ...one, findings: one.findings.map((f, i) => (i === index ? { ...f, state: 'dismissed' as const } : f)) },
343 ),
344 )
345 const dismissed = await stored<string[]>($, 'dismissed', [])
346 await $.store.set('dismissed', [`「${finding.quote}」被当成「${finding.rule}」`, ...dismissed].slice(0, 30))
347 await count($, 'dismissed')
348}
349
350async function openPane($: EngineInterface) {
351 await $.ui.open({ id: PANE, title: '红笔批阅' })
352}
353
354export const register: Register = on => {
355 on('session.start', async ($, e, next) => {
356 await $.command
357 .register({
358 name: 'redpen',
359 description: '红笔批阅:打开面板,看批阅篮、今天的次数、看不懂词典,开关段落按钮和自动红笔',
360 })
361 .catch(() => undefined)
362 sessionCwd = e.cwd
363 await restore($, e.cwd).catch(() => undefined)
364
365 return next(e)
366 })
367
368 // /clear、/resume 会把会话状态清回默认值,而且不会再触发 session.start。
369 // 有公司统一配置时,内置守卫会跳过用户 mod 的 classic.SessionStart,
370 // 所以在会话结束时预约一次补读,等状态清空之后再读回来
371 on('session.end', async ($, e, next) => {
372 const result = await next(e)
373 if (e.reason === 'clear' || e.reason === 'resume') {
374 $.clock.after(500, () => void restore($, sessionCwd).catch(() => undefined))
375 }
376
377 return result
378 })
379
380 on('command.run', { command: 'redpen' }, async $ => {
381 await openPane($)
382
383 return { text: '红笔批阅面板已打开:批阅篮、今天的次数、开关和看不懂词典都在里面。' }
384 })
385
386 // 读者档案只在会话开始时算一次,整段会话不变,不让系统提示的缓存反复失效
387 on('prompt.compose', async ($, e, next) => {
388 const result = await next(e)
389 const text = await read($, section)
390 if (text === '') return result
391
392 return { ...result, sections: [...result.sections, { id: 'red-pen:reader', text, scope: 'session' as const }] }
393 })
394
395 on('turn.complete', async ($, e, next) => {
396 const result = await next(e)
397 if (e.agentId === undefined && e.reason === 'answer') {
398 const answer = e.answer
399 $.clock.after(10, () => void reviewAnswer($, answer).catch(() => undefined))
400 }
401
402 return result
403 })
404
405 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
406 if (e.surface === 'mobile' || e.props.isSummary === true || e.props.text.trim() === '') return next(e)
407
408 const conf = await read($, settings)
409 const review = conf.autoReview
410 ? (await read($, reviews)).find(one => matchesAnswer(one.norm, e.props.text))
411 : undefined
412 if (!conf.enabled && review === undefined) return next(e)
413
414 const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e)
415 const requestId = e.requestId
416 const member = memberOf(blocks, e)
417 const block = conf.enabled ? await read($, member) : EMPTY_BLOCK
418 const units = conf.enabled ? splitUnits(e.props.text) : [{ id: 'all', text: e.props.text }]
419 const now = await $.clock.now()
420
421 const say = (mark: PenMark) =>
422 mark.verdict === 'ok' ? '采纳' : mark.verdict === 'no' ? '不要' : `批注:${mark.note}`
423
424 const toggleOpen = (unit: Unit) =>
425 void update($, member, old => ({ ...old, open: old.open === unit.id ? '' : unit.id }))
426
427 // 每段右边留一条固定宽的窄边,「✎」放在里面、和段落最后一行对齐:
428 // 窄边一直占着,「✎」悬停才出现,所以既不盖字、也不让文字重新换行;点了才展开工具栏
429 const unitRow = (unit: Unit) => {
430 const mark = block.marks[unit.id]
431 const explain = block.explains[unit.id]
432 const isEditing = block.editing === unit.id
433 const isOpen = block.open === unit.id
434 const isStuck = explain?.status === 'asking' && now - (explain.ticket ?? 0) > ASK_TIMEOUT_MS
435 const explainHead =
436 explain?.status !== 'asking'
437 ? '啥意思:'
438 : isStuck
439 ? '啥意思:等了太久没回来。点「✎」→「收起解释」,再点「啥意思」重问'
440 : '啥意思:正在问…'
441
442 return (
443 <Box key={`u-${unit.id}`} flexDirection="column">
444 <Box flexDirection="row">
445 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
446 <Markdown text={unit.text} />
447 </Box>
448 <Box width={3} flexShrink={0} alignSelf="flex-end" justifyContent="flex-end">
449 {isOpen ? (
450 <Button key={`pen-${unit.id}`} label="✎" plain onPress={() => toggleOpen(unit)} />
451 ) : (
452 <Box display="none" hover={{ display: 'flex' }}>
453 <Button key={`pen-${unit.id}`} label="✎" plain dimColor onPress={() => toggleOpen(unit)} />
454 </Box>
455 )}
456 </Box>
457 </Box>
458 {mark !== undefined && <Text color="error">{` ✎ ${say(mark)}`}</Text>}
459 {explain !== undefined && (
460 <Box flexDirection="column" paddingLeft={2}>
461 <Text dimColor>{explainHead}</Text>
462 {explain.status !== 'asking' && <Markdown text={explain.text} dimColor />}
463 </Box>
464 )}
465 {isEditing && (
466 <Input
467 key={`in-${unit.id}`}
468 placeholder="写批注,回车放进批阅篮;清空再回车就是撤回"
469 value={mark?.verdict === 'note' ? mark.note : ''}
470 submitLabel="放进批阅篮"
471 autoFocus
472 onSubmit={value => void saveNote($, requestId, unit, value)}
473 />
474 )}
475 {isOpen && (
476 <Box gap={1}>
477 <Button
478 key={`ok-${unit.id}`}
479 label={mark?.verdict === 'ok' ? '✓ 采纳' : '采纳'}
480 dimColor
481 onPress={() => void toggleVerdict($, requestId, unit, 'ok')}
482 />
483 <Button
484 key={`no-${unit.id}`}
485 label={mark?.verdict === 'no' ? '✓ 不要' : '不要'}
486 dimColor
487 onPress={() => void toggleVerdict($, requestId, unit, 'no')}
488 />
489 <Button
490 key={`ask-${unit.id}`}
491 label={explain === undefined ? '啥意思' : '收起解释'}
492 dimColor
493 onPress={() => void ask($, requestId, unit)}
494 />
495 <Button
496 key={`note-${unit.id}`}
497 label={mark?.verdict === 'note' ? '改批注' : '批注'}
498 dimColor
499 onPress={() =>
500 void update($, member, old => ({
501 ...old,
502 editing: old.editing === unit.id ? '' : unit.id,
503 open: '',
504 }))
505 }
506 />
507 <Button key={`close-${unit.id}`} label="收起" dimColor onPress={() => toggleOpen(unit)} />
508 </Box>
509 )}
510 </Box>
511 )
512 }
513
514 const reviewBox = (one: PenReview) => {
515 if (one.status === 'checking') return <Text dimColor>红笔正在批这条回答…</Text>
516 const open = one.findings.map((f, i) => ({ f, i })).filter(({ f }) => f.state === 'open')
517 if (open.length === 0) return null
518
519 return (
520 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="error" paddingX={1}>
521 <Text color="error" bold>
522 红笔 {open.length} 处
523 </Text>
524 {open.map(({ f, i }) => (
525 <Box key={`f-${i}`} flexDirection="column">
526 <Box gap={1}>
527 <Text>「{f.quote}」</Text>
528 <Text dimColor>{f.rule}</Text>
529 </Box>
530 <Text dimColor>{` 建议:${f.fix}`}</Text>
531 <Box gap={1}>
532 <Button key={`fix-${i}`} label="照改" onPress={() => void applyFindings($, one.id, [i])} />
533 <Button key={`skip-${i}`} label="这条不算" dimColor onPress={() => void dismissFinding($, one.id, i)} />
534 </Box>
535 </Box>
536 ))}
537 {open.length > 1 && (
538 <Button
539 key="fix-all"
540 label="全部照改"
541 variant="primary"
542 onPress={() => void applyFindings($, one.id, open.map(({ i }) => i))}
543 />
544 )}
545 </Box>
546 )
547 }
548
549 return (
550 <Box flexDirection="column">
551 {units.map(unit => (conf.enabled ? unitRow(unit) : <Markdown text={unit.text} />))}
552 {review !== undefined && reviewBox(review)}
553 </Box>
554 )
555 })
556
557 // 批阅篮有东西时才占输入框上方那一条,空了就让给别的插件
558 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
559 if (e.props.hasSurvey) return next(e)
560 const items = await read($, basket)
561 if (items.length === 0) return next(e)
562
563 const accept = await read($, allAccept)
564 const n = (verdict: string) => items.filter(item => item.verdict === verdict).length
565 const { Box, Text, Button } = $.ui.resolve(e)
566
567 return (
568 <Box gap={1} flexWrap="wrap">
569 <Text color="error" bold>
570 红笔
571 </Text>
572 <Text>已批 {items.length} 条</Text>
573 <Text dimColor>
574 (采纳 {n('ok')} · 不要 {n('no')} · 批注 {n('note')})
575 </Text>
576 <Button
577 key="accept"
578 label={accept ? '✓ 其余都采纳' : '其余都采纳'}
579 dimColor={!accept}
580 onPress={() => void update($, allAccept, value => !value)}
581 />
582 <Button key="preview" label="预览" onPress={() => void openPane($)} />
583 <Button key="send" label="发出" variant="primary" onPress={() => void send($)} />
584 <Button key="clear" label="清空" dimColor onPress={() => void clearBasket($)} />
585 </Box>
586 )
587 })
588
589 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
590 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
591 const items = await read($, basket)
592 const accept = await read($, allAccept)
593 const conf = await read($, settings)
594 const stats = await read($, today)
595 const words = await read($, dict)
596 const from = await read($, rulesFrom)
597 const date = dayOf(await $.clock.now())
598 const day = stats.date === date ? stats : emptyDay(date)
599 const say = (item: PenBasketItem) =>
600 item.verdict === 'ok' ? '采纳' : item.verdict === 'no' ? '不要' : item.note
601
602 return (
603 <Box flexDirection="column" gap={1}>
604 <Box flexDirection="column">
605 <Text bold>批阅篮 · {items.length} 条</Text>
606 {items.length === 0 && (
607 <Text dimColor>还没批。鼠标停在 Claude 回答的某一段上,点「采纳 / 不要 / 啥意思 / 批注」。</Text>
608 )}
609 {items.map(item => (
610 <Box key={`b-${item.key}`} gap={1}>
611 <Text wrap="truncate-end">
612 {item.quote}——{say(item)}
613 </Text>
614 <Button
615 key={`del-${item.key}`}
616 label="删掉"
617 dimColor
618 onPress={() => void setMark($, item.requestId, { id: item.unitId, text: '' }, null)}
619 />
620 </Box>
621 ))}
622 {items.length > 0 && (
623 <Box flexDirection="column" marginTop={1}>
624 <Text dimColor>发出去会是这样:</Text>
625 <Markdown text={'```\n' + composeMessage(items, accept) + '\n```'} />
626 <Box gap={1}>
627 <Button
628 key="p-accept"
629 label={accept ? '✓ 其余都采纳' : '其余都采纳'}
630 dimColor={!accept}
631 onPress={() => void update($, allAccept, value => !value)}
632 />
633 <Button key="p-send" label="发出" variant="primary" onPress={() => void send($)} />
634 <Button key="p-clear" label="清空" dimColor onPress={() => void clearBasket($)} />
635 </Box>
636 </Box>
637 )}
638 </Box>
639
640 <Box flexDirection="column">
641 <Text bold>今天</Text>
642 <Text>
643 批了 {day.marks} 段 · 发出 {day.sent} 次 · 问「啥意思」{day.explains} 次
644 </Text>
645 <Text>
646 红笔批了 {day.reviews} 条回答,挑出 {day.findings} 处(照改 {day.applied} · 不算 {day.dismissed})
647 </Text>
648 </Box>
649
650 <Box flexDirection="column">
651 <Text bold>开关</Text>
652 <Box gap={1}>
653 <Button
654 key="t-enabled"
655 label={conf.enabled ? '段落按钮:开' : '段落按钮:关'}
656 onPress={() => void saveSettings($, old => ({ ...old, enabled: !old.enabled }))}
657 />
658 <Button
659 key="t-review"
660 label={conf.autoReview ? '自动红笔:开' : '自动红笔:关'}
661 onPress={() => void saveSettings($, old => ({ ...old, autoReview: !old.autoReview }))}
662 />
663 </Box>
664 <Text dimColor wrap="truncate-end">
665 规矩来自:{from} · 去掉代码后正文超过 {conf.minChars} 字的回答才批
666 </Text>
667 </Box>
668
669 <Box flexDirection="column">
670 <Text bold>看不懂词典 · {words.length} 个</Text>
671 <Text dimColor>最近的 30 个词会在新会话开始时告诉 Claude:少用,或者先解释。记错的词可以删掉。</Text>
672 {words.slice(0, 30).map(word => (
673 <Box key={`w-${word.term}`} gap={1}>
674 <Text wrap="truncate-end">
675 {word.term}:{word.explain}
676 </Text>
677 <Button key={`del-w-${word.term}`} label="删掉" dimColor onPress={() => void removeWord($, word.term)} />
678 </Box>
679 ))}
680 {words.length > 0 && (
681 <Button key="clear-dict" label="清空词典" dimColor onPress={() => void changeDict($, () => [])} />
682 )}
683 </Box>
684 </Box>
685 )
686 })
687}
688hooks/text.ts 258 lines1import type { PenBasketItem, PenDayStats, PenDictEntry, PenFinding } from '../types'
2
3// 回答里能单独批阅的一段:空行隔开的一块;列表按顶层条目再拆开;代码块整块算一段
4export type Unit = { id: string; text: string }
5
6const FENCE = /^\s*(```|~~~)/
7const TOP_ITEM = /^ {0,1}([-*+]|\d+[.)])\s+/
8
9// 32 位 FNV-1a,够给段落起一个稳定的短 id
10export function hash(text: string): string {
11 let h = 0x811c9dc5
12 for (let i = 0; i < text.length; i++) {
13 h ^= text.charCodeAt(i)
14 h = Math.imul(h, 0x01000193)
15 }
16
17 return (h >>> 0).toString(36)
18}
19
20function splitBlocks(markdown: string): string[] {
21 const blocks: string[] = []
22 let current: string[] = []
23 let inFence = false
24 const flush = () => {
25 if (current.some(line => line.trim() !== '')) blocks.push(current.join('\n'))
26 current = []
27 }
28
29 for (const line of markdown.split('\n')) {
30 if (FENCE.test(line)) {
31 inFence = !inFence
32 current.push(line)
33 continue
34 }
35 if (!inFence && line.trim() === '') {
36 flush()
37 continue
38 }
39 current.push(line)
40 }
41 flush()
42
43 return blocks
44}
45
46// 一块里有顶层列表条目,就按条目拆:条目前的引导句单独一段,缩进的子条目跟着上一条。
47// 逐行看是不是在代码块里:代码块里的「- xxx」(yaml 列表、diff 的删除行)不拆
48function splitItems(block: string): string[] {
49 const parts: string[] = []
50 let current: string[] = []
51 let inFence = false
52 for (const line of block.split('\n')) {
53 if (FENCE.test(line)) {
54 inFence = !inFence
55 } else if (!inFence && TOP_ITEM.test(line) && current.length > 0) {
56 parts.push(current.join('\n'))
57 current = []
58 }
59 current.push(line)
60 }
61 if (current.length > 0) parts.push(current.join('\n'))
62
63 return parts
64}
65
66export function splitUnits(markdown: string): Unit[] {
67 const seen = new Map<string, number>()
68
69 return splitBlocks(markdown)
70 .flatMap(splitItems)
71 .map(text => {
72 const base = hash(text)
73 const count = seen.get(base) ?? 0
74 seen.set(base, count + 1)
75
76 return { id: count === 0 ? base : `${base}-${count}`, text }
77 })
78}
79
80// 把一段 markdown 压成一行引文:去掉标记,超长截断加「……」。
81// 代码块引第一行代码、表格引第一行内容,批了好几个代码块时 Claude 也分得清是哪个
82export function plainQuote(markdown: string, max = 36): string {
83 const cut = (text: string) => (text.length > max ? `${text.slice(0, max)}……` : text)
84 const lines = markdown.split('\n').filter(line => line.trim() !== '')
85 const first = lines[0] ?? ''
86 if (FENCE.test(first)) {
87 const code = lines.slice(1).find(line => !FENCE.test(line)) ?? ''
88 return cut(`代码:${code.trim()}`)
89 }
90 if (/^\s*\|/.test(first)) {
91 const cells = first.split('|').map(cell => cell.replace(/[*_`]/g, '').trim()).filter(cell => cell !== '')
92 return cut(`表格:${cells.join(' / ')}`)
93 }
94
95 const plain = markdown
96 .replace(/\s*```[^\n]*\n\s*([^\n]*)[\s\S]*?(```|$)/g, (_, code: string) => `(代码:${code.trim()})`)
97 .replace(/^\s*([-*+]|\d+[.)])\s+/gm, '')
98 .replace(/^\s*#+\s*/gm, '')
99 .replace(/^\s*>\s?/gm, '')
100 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
101 .replace(/[*_`~]/g, '')
102 .replace(/\s+/g, ' ')
103 .trim()
104
105 return cut(plain)
106}
107
108// 回答里去掉代码块剩下的正文:红笔只批正文,按正文字数判断够不够长、是不是以代码为主
109export function proseOf(answer: string): string {
110 return answer.replace(/^\s*(```|~~~)[\s\S]*?^\s*(```|~~~)\s*$/gm, '(代码块略)')
111}
112
113// 批阅篮拼成平时手打批注的格式:一行一条「原文……——批注」
114export function composeMessage(items: readonly PenBasketItem[], allAccept: boolean): string {
115 const lines = [...items]
116 .sort((a, b) => a.at - b.at)
117 .map(item => {
118 const say = item.verdict === 'ok' ? '采纳' : item.verdict === 'no' ? '不要' : item.note
119 return `${item.quote}——${say}`
120 })
121 if (allAccept) lines.push('其余都采纳')
122
123 return lines.join('\n')
124}
125
126export function normalize(text: string): string {
127 return text.replace(/\s+/g, '')
128}
129
130// 红笔结果对上画面里的哪条回答:全文一致,或者这条是回答的最后一块
131export function matchesAnswer(answerNorm: string, blockText: string): boolean {
132 const block = normalize(blockText)
133 if (block.length < 20) return false
134
135 return answerNorm === block || (block.length >= 40 && answerNorm.endsWith(block))
136}
137
138// 内置的说人话规矩:同事装上没配规矩文件时用这一套
139export const DEFAULT_RULES = [
140 '1. 不用没解释的英文词、缩写和代号;团队常识(DAU、PRD、PM、AI、API、Bug)不算',
141 '2. 不自造标签和简称(比如「线A」「三件套」),直接用大白话说',
142 '3. 结论先行:第一句就给结论或答案,不先铺垫背景',
143 '4. 数字多时放进表格或列表,一句话里不堆 3 个以上数字',
144 '5. 不写过程话和元评论(「下面我来…」「总结一下」「好问题」)',
145 '6. 同一个意思不说两遍,能删的就删',
146].join('\n')
147
148export const REVIEW_SYSTEM =
149 '你是中文写作的批改老师。按给定规矩检查一段 AI 写给读者的回答,只挑真正违反规矩、会让读者卡住的地方,最多 3 处;没有就输出 []。文件名、路径、命令、代码、链接和产品名不算黑话,不要挑。只输出 JSON,不要别的字。'
150
151export function reviewPrompt(answer: string, dismissed: readonly string[]): string {
152 const skip =
153 dismissed.length === 0
154 ? ''
155 : `\n\n读者判过「这条不算」的例子,同类的不要再挑:\n${dismissed.map(d => `- ${d}`).join('\n')}`
156
157 return `要检查的回答:\n<<<\n${answer}\n>>>${skip}\n\n输出 JSON 数组,每项是 {"quote": 回答里逐字出现的原文片段(不超过 30 字), "rule": 违反的规矩,写编号和四个字以内的简称(如「1 黑话」), "fix": 怎么改(不超过 30 字)}`
158}
159
160// 解析红笔结果:只留原文里真找得到的片段,最多 3 处
161export function parseFindings(reply: string, answer: string): PenFinding[] {
162 const start = reply.indexOf('[')
163 const end = reply.lastIndexOf(']')
164 if (start < 0 || end <= start) return []
165
166 let raw: unknown
167 try {
168 raw = JSON.parse(reply.slice(start, end + 1))
169 } catch {
170 return []
171 }
172 if (!Array.isArray(raw)) return []
173
174 const haystack = normalize(answer)
175 const findings: PenFinding[] = []
176 for (const item of raw) {
177 if (typeof item !== 'object' || item === null) continue
178 const { quote, rule, fix } = item as Record<string, unknown>
179 if (typeof quote !== 'string' || typeof rule !== 'string' || typeof fix !== 'string') continue
180 if (quote.trim() === '' || !haystack.includes(normalize(quote))) continue
181 findings.push({ quote: quote.trim(), rule: rule.trim(), fix: fix.trim(), state: 'open' })
182 if (findings.length === 3) break
183 }
184
185 return findings
186}
187
188export const EXPLAIN_PROMPT =
189 '(这是红笔批阅插件的旁路提问,你的回答不会进入对话。)读者看不懂下面这段里的一些说法。请结合上面的对话,用大白话解释这段里普通人可能看不懂的词、缩写、代号或编号,每个一行,格式「词:解释」,解释不超过 25 字,最多 5 个。没有就只回「没有难懂的词」。\n\n这段是:\n'
190
191// 解释有好几行时排成列表:Markdown 会把单个换行当空格,不排的话几个词会挤成一段
192export function explainMarkdown(reply: string): string {
193 const lines = reply
194 .split('\n')
195 .map(line => line.trim())
196 .filter(line => line !== '')
197 if (lines.length <= 1) return lines[0] ?? ''
198
199 return lines.map(line => (/^([-*+]|\d+[.)])\s/.test(line) ? line : `- ${line}`)).join('\n')
200}
201
202// 「词:解释」一行一个,抽出来进词典
203export function parseTerms(reply: string, at: number): PenDictEntry[] {
204 const entries: PenDictEntry[] = []
205 for (const line of reply.split('\n')) {
206 const found = /^\s*(?:[-*]\s*)?\**(.{1,30}?)\**\s*[::]\s*(.{2,80})$/.exec(line)
207 if (found === null) continue
208 const term = (found[1] ?? '').replace(/[「」"“”]/g, '').trim()
209 const explain = (found[2] ?? '').trim()
210 if (term !== '' && explain !== '') entries.push({ term, explain, at })
211 }
212
213 return entries
214}
215
216// 新词放前面,同名的只留最新的
217export function mergeDict(old: readonly PenDictEntry[], fresh: readonly PenDictEntry[], max = 200): PenDictEntry[] {
218 const seen = new Set<string>()
219 const merged: PenDictEntry[] = []
220 for (const entry of [...fresh, ...old]) {
221 if (seen.has(entry.term)) continue
222 seen.add(entry.term)
223 merged.push(entry)
224 }
225
226 return merged.slice(0, max)
227}
228
229// 写进系统提示的一段:问过的词 + 最常被红笔挑出的两类问题。会话开始时算一次,中途不变
230export function readerSection(dict: readonly PenDictEntry[], days: readonly PenDayStats[]): string {
231 const lines: string[] = []
232 if (dict.length > 0) {
233 lines.push('读者问过「啥意思」的词。写给这位读者时尽量不用;非用不可,就在第一次出现时用一句大白话解释:')
234 for (const entry of dict.slice(0, 30)) lines.push(`- ${entry.term}:${entry.explain}`)
235 }
236
237 const counts = new Map<string, number>()
238 for (const day of days) {
239 for (const [rule, n] of Object.entries(day.byRule)) counts.set(rule, (counts.get(rule) ?? 0) + n)
240 }
241 const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2)
242 if (top.length > 0) {
243 lines.push(`最近最常被读者的红笔挑出的问题:${top.map(([rule, n]) => `${rule}(${n} 次)`).join('、')}。写的时候先避开这两类。`)
244 }
245 if (lines.length === 0) return ''
246
247 return ['# 红笔批阅:这位读者的阅读习惯', ...lines].join('\n')
248}
249
250// 北京时间的日期,例如 2026-10-14
251export function dayOf(ms: number): string {
252 return new Date(ms + 8 * 3600_000).toISOString().slice(0, 10)
253}
254
255export function emptyDay(date: string): PenDayStats {
256 return { date, marks: 0, sent: 0, explains: 0, reviews: 0, findings: 0, applied: 0, dismissed: 0, byRule: {} }
257}
258types/index.d.ts 79 lines1// 一段的批阅结论:采纳、不要,或写了批注
2export type PenVerdict = 'ok' | 'no' | 'note'
3export type PenMark = { verdict: PenVerdict; note: string }
4
5// 「啥意思」的解释,问的时候是 asking
6export type PenExplain = { status: 'asking' | 'done' | 'failed'; text: string; ticket?: number }
7
8// 一条回答(一个 requestId)上各段的批阅状态,键是段落 id;
9// open 是点了「✎」展开工具栏的那一段,editing 是正在写批注的那一段
10export type PenBlock = {
11 marks: Record<string, PenMark>
12 explains: Record<string, PenExplain>
13 editing: string
14 open?: string
15}
16
17// 批阅篮里的一条,key = requestId + 段落 id
18export type PenBasketItem = {
19 key: string
20 requestId: string
21 unitId: string
22 quote: string
23 verdict: PenVerdict
24 note: string
25 at: number
26}
27
28// 红笔挑出的一处
29export type PenFinding = {
30 quote: string
31 rule: string
32 fix: string
33 state: 'open' | 'applied' | 'dismissed'
34}
35
36// 一条长回答的红笔结果,norm 是去掉空白的回答原文,用来对上画面里的那条回答
37export type PenReview = {
38 id: string
39 head?: string
40 norm: string
41 status: 'checking' | 'done' | 'failed'
42 findings: PenFinding[]
43 at: number
44}
45
46export type PenSettings = { enabled: boolean; autoReview: boolean; minChars: number }
47
48// 当天的使用次数,按北京时间日期
49export type PenDayStats = {
50 date: string
51 marks: number
52 sent: number
53 explains: number
54 reviews: number
55 findings: number
56 applied: number
57 dismissed: number
58 byRule: Record<string, number>
59}
60
61// 看不懂词典的一条
62export type PenDictEntry = { term: string; explain: string; at: number }
63
64declare module 'claude-code' {
65 interface PluginState {
66 'red-pen': {
67 blocks: StateFamily<PenBlock>
68 basket: PenBasketItem[]
69 allAccept: boolean
70 reviews: PenReview[]
71 settings: PenSettings
72 today: PenDayStats
73 dict: PenDictEntry[]
74 section: string
75 rulesFrom: string
76 }
77 }
78}
79