按一下「中文」按鈕,Claude 的回覆和選擇題都會變成繁體中文

Claude Code 的兩個 mod:
/zh,Claude 的回覆和選擇題都會變成繁體中文(只翻譯最新一則回覆)。在終端機的 Claude Code 對話框輸入:
/plugin install all --marketplace justin20745413/claude-mods
第一次會問 Add marketplace?,按 y;接著選安裝範圍,直接按 Enter(user)即可。file-log 和 zh-translate 會一起裝好。
在一般終端機(shell)裡:
claude plugin install all --marketplace justin20745413/claude-mods
/plugin install no-game --marketplace justin20745413/claude-mods
會裝 file-log-nogame 和 zh-translate。file-log-nogame 跟 file-log 功能一樣,但不含貪食蛇和小恐龍,面板上也沒有 game 分頁。(file-log 和 file-log-nogame 選一個裝就好。)
/plugin install file-log --marketplace justin20745413/claude-mods
/plugin install file-log-nogame --marketplace justin20745413/claude-mods
/plugin install zh-translate --marketplace justin20745413/claude-mods
在桌面版 App(/plugin 不能用時):點對話框旁的 + → Plugins → Add marketplace,輸入 justin20745413/claude-mods,再按 Add plugin,選 all 或單一個 mod。
裝過的人要拿到新版本:
claude plugin marketplace update my-mods
claude plugin update file-log@my-mods
claude plugin update zh-translate@my-mods
(裝的是無遊戲版的話,把 file-log@my-mods 換成 file-log-nogame@my-mods。)
file-log-nogame 是由 file-log 自動產生的,不要直接改它。改完 file-log 後執行:
./scripts/make-nogame.shhooks/register.tsx 225 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// 中文模式:
5// 1. Claude 之後的回覆直接用繁體中文寫(每個提示旁邊附一句只有模型看得到的指示)
6// 2. 最新一則英文回覆翻譯後顯示(只改顯示,對話紀錄不變);翻譯只留這一則,下一輪就清掉
7// 3. 選擇題(AskUserQuestion)在跳出前先翻成中文
8
9const isOn = atom({ plugin: 'zh-translate', key: 'isOn' } as const, false)
10const translations = atom({ plugin: 'zh-translate', key: 'translations' } as const, {} as Record<string, string>)
11const latest = atom({ plugin: 'zh-translate', key: 'latest' } as const, '')
12
13const STORE_KEY = 'isOn'
14// The cheapest Claude model ($0.10 / $0.50 per million tokens), thinking kept to a minimum.
15const MODEL = 'claude-haiku-5-5'
16const EFFORT = 'low' as const
17const MAX_PARALLEL = 3
18
19const REPLY_IN_CHINESE =
20 'The user has turned on Chinese mode: write every reply to the user in Traditional Chinese (繁體中文, Taiwan usage), ' +
21 'including AskUserQuestion questions, headers, option labels and descriptions. ' +
22 'Keep code, commands, file paths and identifiers as they are.'
23
24const TRANSLATE_SYSTEM =
25 'You translate text into Traditional Chinese (繁體中文, Taiwan usage). ' +
26 'Keep Markdown structure, code blocks, inline code, commands, file paths, URLs, numbers and product names unchanged. ' +
27 'Reply with the translation only, no preamble.'
28
29// Text that is already mostly Chinese, or has no letters to translate, is left alone.
30export function needsTranslation(text: string): boolean {
31 const prose = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
32 const cjk = (prose.match(/[㐀-鿿]/g) ?? []).length
33 const latinWords = (prose.match(/[A-Za-z]{2,}/g) ?? []).length
34 if (latinWords < 3) return false
35 return latinWords > cjk / 2
36}
37
38// A block belongs to the newest reply when its text is part of that reply's final text.
39export function isLatestReply(text: string, latestAnswer: string): boolean {
40 const t = text.trim()
41 return t !== '' && latestAnswer !== '' && latestAnswer.includes(t)
42}
43
44function hash(text: string): string {
45 let h = 5381
46 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
47 return h.toString(36) + ':' + text.length
48}
49
50// ---- Reply blocks: translated in the background, a few at a time ----
51
52const pending = new Set<string>()
53const queue: { key: string; text: string }[] = []
54let running = 0
55
56function requestTranslation($: EngineInterface, key: string, text: string): void {
57 if (pending.has(key)) return
58 pending.add(key)
59 queue.push({ key, text })
60 pump($)
61}
62
63function pump($: EngineInterface): void {
64 while (running < MAX_PARALLEL && queue.length > 0) {
65 const job = queue.shift()!
66 running++
67 void translateBlock($, job.key, job.text).finally(() => {
68 running--
69 pending.delete(job.key)
70 pump($)
71 })
72 }
73}
74
75async function translateBlock($: EngineInterface, key: string, text: string): Promise<void> {
76 const reply = await $.model.complete({
77 model: MODEL,
78 effort: EFFORT,
79 system: TRANSLATE_SYSTEM,
80 prompt: text,
81 maxTokens: Math.min(16000, text.length * 2 + 256),
82 })
83 if (!reply.isAnswered || !reply.text.trim()) return
84 // A newer reply arrived while this one was translating: drop it.
85 if (!isLatestReply(text, await read($, latest))) return
86 await update($, translations, all => ({ ...all, [key]: reply.text.trim() }))
87}
88
89// ---- Questions: every shown string translated in one call, before the dialog opens ----
90
91type Option = { label?: unknown; description?: unknown; [k: string]: unknown }
92type Question = { question?: unknown; header?: unknown; options?: unknown; [k: string]: unknown }
93
94export async function translateQuestions($: EngineInterface, questions: readonly Question[]): Promise<Question[] | undefined> {
95 const strings: string[] = []
96 const take = (v: unknown) => {
97 if (typeof v === 'string') strings.push(v)
98 }
99 for (const q of questions) {
100 take(q.question)
101 take(q.header)
102 for (const o of Array.isArray(q.options) ? (q.options as Option[]) : []) {
103 take(o.label)
104 take(o.description)
105 }
106 }
107 if (!needsTranslation(strings.join('\n'))) return undefined
108
109 const reply = await $.model.complete({
110 model: MODEL,
111 effort: EFFORT,
112 system:
113 TRANSLATE_SYSTEM +
114 ' The input is a JSON array of strings; reply with a JSON array of the same length, each string translated in place. ' +
115 'Strings that are short headers must stay at most 12 characters.',
116 prompt: JSON.stringify(strings),
117 maxTokens: Math.min(8000, JSON.stringify(strings).length * 2 + 256),
118 })
119 if (!reply.isAnswered) return undefined
120 let out: unknown
121 try {
122 out = JSON.parse(reply.text.slice(reply.text.indexOf('['), reply.text.lastIndexOf(']') + 1))
123 } catch {
124 return undefined
125 }
126 if (!Array.isArray(out) || out.length !== strings.length || !out.every(s => typeof s === 'string')) return undefined
127
128 let i = 0
129 const next = (v: unknown) => (typeof v === 'string' ? (out as string[])[i++] : v)
130 return questions.map(q => {
131 const question = next(q.question)
132 const header = next(q.header)
133 const options = Array.isArray(q.options)
134 ? (q.options as Option[]).map(o => {
135 const label = next(o.label)
136 const description = next(o.description)
137 return { ...o, label, description }
138 })
139 : q.options
140 return {
141 ...q,
142 question,
143 header: typeof header === 'string' ? header.slice(0, 12) : header,
144 options,
145 }
146 })
147}
148
149async function setOn($: EngineInterface, on: boolean): Promise<void> {
150 await update($, isOn, () => on)
151 await $.store.set(STORE_KEY, on)
152 $.ui.toast(on ? '中文模式已開啟' : '中文模式已關閉')
153}
154
155export const register: Register = on => {
156 on('session.start', async ($, e, next) => {
157 const stored = (await $.store.get(STORE_KEY)) === true
158 await update($, isOn, () => stored)
159 await $.command.register({ name: 'zh', description: '切換中文模式:Claude 的回覆和選擇題都變成繁體中文' })
160 return next(e)
161 })
162
163 on('command.run', { command: 'zh' }, async $ => {
164 const now = !(await read($, isOn))
165 await setOn($, now)
166 return { text: now ? '中文模式:開' : '中文模式:關' }
167 })
168
169 // Future replies: ask the model itself to write in Chinese.
170 on('prompt.submit', async ($, e, next) => {
171 if (e.command || !(await read($, isOn))) return next(e)
172 return next({ ...e, context: [...(e.context ?? []), REPLY_IN_CHINESE] })
173 })
174
175 // Questions: translate before the dialog opens, so the choices are Chinese too.
176 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
177 if (!(await read($, isOn))) return next(e)
178 const translated = await translateQuestions($, e.questions as readonly Question[])
179 return next(translated ? ({ ...e, questions: translated } as typeof e) : e)
180 }).catch(($, e, next) => next(e))
181
182 // Remember the newest reply and forget older translations.
183 on('turn.complete', async ($, e, next) => {
184 const done = await next(e)
185 if (e.agentId === undefined) {
186 const answer = e.answer.trim()
187 await update($, latest, () => answer)
188 await update($, translations, () => ({}))
189 }
190 return done
191 })
192
193 // The newest reply on screen: show the translation once it is ready.
194 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
195 if (!(await read($, isOn))) return next(e)
196 const text = e.props.text
197 if (!needsTranslation(text)) return next(e)
198 if (!isLatestReply(text, await read($, latest))) return next(e)
199 const key = hash(text)
200 const done = (await read($, translations))[key]
201 if (done) return next({ ...e, props: { ...e.props, text: done } })
202 // Only rows in view are translated; scrolling to the others brings them in.
203 if (e.props.onScreen !== null) requestTranslation($, key, text)
204 return next(e)
205 })
206
207 // The button above the prompt.
208 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209 if (e.props.hasSurvey) return next(e)
210 const { Box, Button, Text } = $.ui.resolve(e)
211 const active = await read($, isOn)
212 // Other mods' bands are drawn below this one, not replaced.
213 const below = await next(e)
214 return (
215 <Box flexDirection="column">
216 <Box flexDirection="row" gap={1}>
217 <Button key="zh-toggle" variant={active ? 'primary' : 'secondary'} label={active ? '中文 開' : '中文 關'} onPress={() => setOn($, !active)} />
218 <Text dimColor>{active ? '回覆與選擇題會顯示成繁體中文' : '點一下或輸入 /zh 切換成中文'}</Text>
219 </Box>
220 {below}
221 </Box>
222 )
223 })
224}
225types/index.d.ts 16 lines1/** Whether Chinese mode is on. */
2export type ZhMode = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 'zh-translate': {
7 /** Whether Chinese mode is on. */
8 isOn: ZhMode
9 /** Translations of the newest reply's blocks only, by a hash of the original text. */
10 translations: Record<string, string>
11 /** The newest reply's final text, so only it is translated. */
12 latest: string
13 }
14 }
15}
16