From 7873828bc3632262f0354ff2c494a997de612858 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 06:07:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=201=20=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=EF=BC=88=E6=8F=92=E4=BB=B6=E3=80=81=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=95=9F=E5=8B=95=EF=BC=8F=E9=9B=A2=E9=96=8B=E3=80=81persona?= =?UTF-8?q?=E3=80=81/config=E3=80=81=E7=8B=80=E6=85=8B=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/marketplace.json | 14 +++ .claude-plugin/plugin.json | 111 +++++++++++++++++++ LICENSE | 21 ++++ README.md | 33 ++++++ hooks/anima.test.ts | 49 +++++++++ hooks/characters.ts | 69 ++++++++++++ hooks/hooks.json | 1 + hooks/register.tsx | 183 ++++++++++++++++++++++++++++++++ hooks/statusline.ts | 61 +++++++++++ icon.png | Bin 0 -> 6199 bytes 10 files changed, 542 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 LICENSE create mode 100644 hooks/anima.test.ts create mode 100644 hooks/characters.ts create mode 100644 hooks/hooks.json create mode 100644 hooks/register.tsx create mode 100644 hooks/statusline.ts create mode 100644 icon.png diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..d4c68a3 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "jsc-anima", + "owner": { + "name": "jsc" + }, + "plugins": [ + { + "name": "jsc-anima", + "source": "./", + "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列" + } + ], + "description": "jsc 的動漫助理插件" +} \ No newline at end of file diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..4b9d6c0 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,111 @@ +{ + "name": "jsc-anima", + "version": "0.1.0", + "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列", + "license": "MIT", + "userConfig": { + "enabled": { + "type": "boolean", + "title": "啟用助理", + "description": "關閉後停止所有 persona 注入與狀態列", + "default": true + }, + "persist": { + "type": "boolean", + "title": "跨 session 保留啟動", + "description": "下次開啟時自動恢復上次的角色", + "default": true + }, + "statusbar": { + "type": "boolean", + "title": "顯示狀態列", + "description": "輸入框上方的單行狀態列", + "default": true + }, + "fields": { + "type": "string", + "title": "狀態列欄位與順序", + "description": "逗號分隔:character,model,dir,branch,5h,7d,ctx,sys", + "default": "character,model,dir,branch,5h,7d,ctx,sys" + }, + "barStyle": { + "type": "string", + "title": "量表樣式", + "description": "block 或 dot", + "options": [ + "block", + "dot" + ], + "default": "block" + }, + "warnAt": { + "type": "number", + "title": "黃色門檻 (%)", + "description": "使用率達此值轉黃", + "default": 60 + }, + "dangerAt": { + "type": "number", + "title": "紅色門檻 (%)", + "description": "使用率達此值轉紅", + "default": 85 + }, + "refreshSec": { + "type": "number", + "title": "系統資源更新秒數", + "description": "CPU/RAM/DSK 的取樣間隔", + "default": 10 + }, + "axisGentle": { + "type": "number", + "title": "溫柔 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisTsundere": { + "type": "number", + "title": "傲嬌 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisLively": { + "type": "number", + "title": "活潑 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisFormal": { + "type": "number", + "title": "正式 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisTalkative": { + "type": "number", + "title": "話量 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisHumor": { + "type": "number", + "title": "幽默 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisProactive": { + "type": "number", + "title": "主動 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + }, + "axisExpressive": { + "type": "number", + "title": "情緒外顯 (0-10,-1 沿用角色包)", + "description": "覆蓋目前角色的數值軸,只存本機", + "default": -1 + } + }, + "author": { + "name": "jsc" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c7ba78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jsc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6ea2051..1303c54 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ +

jsc-anima

+ # jsc-anima + +動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。 + +## 安裝 + +``` +/plugin install jsc-anima --marketplace jiantw83/jsc-anima +``` + +或用 CLI: + +``` +claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima +claude plugin install jsc-anima +``` + +## 更新 + +``` +claude plugin update jsc-anima +``` + +## 移除 + +``` +claude plugin uninstall jsc-anima +``` + +## 授權 + +[MIT](LICENSE) diff --git a/hooks/anima.test.ts b/hooks/anima.test.ts new file mode 100644 index 0000000..fb2cf4b --- /dev/null +++ b/hooks/anima.test.ts @@ -0,0 +1,49 @@ +import { expect, mock, test } from 'claude-code/testing' + +import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline' +import type { Segment } from './statusline' + +const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] }) + +test('啟動角色後 persona 注入系統提示', async ($, on) => { + mock.store(on) + on('command.register', () => ({ value: { name: 'x' } }) as never) + on('session.start', () => ({ cwd: '/tmp' }) as never) + on('command.run', () => ({ text: '' })) + on('prompt.compose', () => ({ sections: [] })) + await $.session.start({ source: 'startup', cwd: '/tmp' } as never) + await $.command.run({ command: 'sao-asuna', args: '' } as never) + const res = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never) + expect(res.sections.some(s => s.id === 'jsc-anima:persona' && s.text.includes('亞絲娜'))).toBe(true) +}) + +test('切換角色與離開', async ($, on) => { + mock.store(on) + on('command.register', () => ({ value: { name: 'x' } }) as never) + on('session.start', () => ({ cwd: '/tmp' }) as never) + on('command.run', () => ({ text: '' })) + on('prompt.compose', () => ({ sections: [] })) + await $.session.start({ source: 'startup', cwd: '/tmp' } as never) + await $.command.run({ command: 'sao-asuna', args: '' } as never) + await $.command.run({ command: 'sao-suguha', args: '' } as never) + const compose = () => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never) + expect((await compose()).sections.find(s => s.id === 'jsc-anima:persona')?.text).toContain('直葉') + await $.command.run({ command: 'anima-off', args: '' } as never) + expect((await compose()).sections.some(s => s.id === 'jsc-anima:persona')).toBe(false) +}) + +test('狀態列:窄時依序整欄隱藏,不截斷', () => { + const all = [seg('character', 'AB'), seg('model', 'CD'), seg('dir', 'EF'), seg('branch', 'GH'), seg('5h', 'IJ'), seg('7d', 'KL'), seg('ctx', 'MN'), seg('sys', 'OP')] + expect(layout(all, 999).length).toBe(8) + expect(layout(all, 26).map(s => s.id)).not.toContain('sys') + const narrow = layout(all, 14).map(s => s.id) + expect(narrow).toEqual(['character', 'model', '5h', 'ctx']) + expect([...HIDE_ORDER]).toEqual(['sys', '7d', 'dir', 'branch']) +}) + +test('量表固定長度,剩餘越少越紅', () => { + expect(bar(0).length).toBe(bar(100).length) + expect(remainColor(10, 60, 85)).toBe('red') + expect(remainColor(90, 60, 85)).toBe('green') + expect(usedColor(70, 60, 85)).toBe('yellow') +}) diff --git a/hooks/characters.ts b/hooks/characters.ts new file mode 100644 index 0000000..df6e138 --- /dev/null +++ b/hooks/characters.ts @@ -0,0 +1,69 @@ +export type Axes = { + gentle: number + tsundere: number + lively: number + formal: number + talkative: number + humor: number + proactive: number + expressive: number +} + +export type Character = { + slug: string + name: string + work: string + persona: string + axes: Axes +} + +// 角色包只含自行撰寫的人設,不含原作台詞。 +export const CHARACTERS: readonly Character[] = [ + { + slug: 'sao-asuna', + name: '亞絲娜', + work: '刀劍神域', + persona: + '自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。', + axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 }, + }, + { + slug: 'sao-suguha', + name: '直葉', + work: '刀劍神域', + persona: + '自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。', + axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 }, + }, +] + +export const findCharacter = (slug: string | undefined): Character | undefined => + CHARACTERS.find(c => c.slug === slug) + +const AXIS_LABEL: Record = { + gentle: '溫柔', + tsundere: '傲嬌', + lively: '活潑', + formal: '正式', + talkative: '話量', + humor: '幽默', + proactive: '主動', + expressive: '情緒外顯', +} + +export const applyOverrides = (axes: Axes, over: Partial): Axes => { + const out = { ...axes } + for (const k of Object.keys(over) as (keyof Axes)[]) { + const v = over[k] + if (typeof v === 'number' && v >= 0 && v <= 10) out[k] = v + } + return out +} + +export const personaSection = (c: Character, axes: Axes): string => + [ + `你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`, + c.persona, + `個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`, + '預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。', + ].join('\n') diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/hooks/register.tsx b/hooks/register.tsx new file mode 100644 index 0000000..2ac24c6 --- /dev/null +++ b/hooks/register.tsx @@ -0,0 +1,183 @@ +import type { Engine, Register } from 'claude-code' + +import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters' +import type { Axes } from './characters' +import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline' +import type { Segment } from './statusline' + +const ACTIVE_KEY = 'active' +const OFF_COMMAND = 'anima-off' + +const AXIS_OPTION: Record = { + gentle: 'axisGentle', + tsundere: 'axisTsundere', + lively: 'axisLively', + formal: 'axisFormal', + talkative: 'axisTalkative', + humor: 'axisHumor', + proactive: 'axisProactive', + expressive: 'axisExpressive', +} + +const SYS_SCRIPT = + 'a=$(awk \'{print $1}\' /proc/loadavg); n=$(nproc); ' + + 'echo "$a $n"; free | awk \'/Mem:/{printf "%d\\n", $3*100/$2}\'; df / | awk \'NR==2{gsub("%","",$5); print $5}\'' + +const resetText = (iso: string | undefined, long: boolean): string => { + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const p = (n: number) => String(n).padStart(2, '0') + return long ? ` ${d.getMonth() + 1}/${d.getDate()}` : ` ${p(d.getHours())}:${p(d.getMinutes())}` +} + +const fmtTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)) + +type Sys = { at: number; cpu: number; ram: number; dsk: number } + +async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Promise { + const now = await $.clock.now() + if (holder.sys && now - holder.sys.at < refreshMs) return holder.sys + try { + const r = await $.process.run(['sh', '-c', SYS_SCRIPT], { timeoutMs: 3000 }) + const [l1, l2, l3] = String(r.stdout).trim().split('\n') + const [load, cores] = (l1 ?? '').split(' ').map(Number) + holder.sys = { + at: now, + cpu: Math.min(100, Math.round(((load || 0) / (cores || 1)) * 100)), + ram: Number(l2) || 0, + dsk: Number(l3) || 0, + } + } catch { + holder.sys = undefined + } + return holder.sys +} + +export const register: Register = (on, options) => { + const opt = options as Record + const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d) + const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d) + const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d) + + const isEnabled = flag('enabled', true) + let active: string | undefined + const holder: { sys?: Sys } = {} + + const overrides = (): Partial => { + const out: Partial = {} + for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) { + const v = num(AXIS_OPTION[k], -1) + if (v >= 0) out[k] = v + } + return out + } + + on('session.start', async ($, e, next) => { + if (isEnabled) { + for (const c of CHARACTERS) { + await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) + } + await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' }) + const saved = await $.store.get(ACTIVE_KEY) + active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined + } + return next(e) + }) + + on('command.run', async ($, e, next) => { + if (!isEnabled) return next(e) + if (e.command === OFF_COMMAND) { + active = undefined + await $.store.delete(ACTIVE_KEY) + return { text: '已離開角色,回到一般模式。' } + } + const c = findCharacter(e.command) + if (!c) return next(e) + active = c.slug + await $.store.set(ACTIVE_KEY, c.slug) + return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` } + }) + + on('prompt.compose', async ($, e, next) => { + const res = await next(e) + const c = isEnabled ? findCharacter(active) : undefined + if (!c) return res + const text = personaSection(c, applyOverrides(c.axes, overrides())) + return { + ...res, + sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }], + } + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + const c = isEnabled ? findCharacter(active) : undefined + if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e) + + const warn = num('warnAt', 60) + const danger = num('dangerAt', 85) + const style = str('barStyle', 'block') === 'dot' ? 'dot' : 'block' + const usage = await $.session.usage().catch(() => undefined) + const cwd = await $.session.cwd().catch(() => '') + const branch = await $.process + .run(['git', 'branch', '--show-current'], { cwd: cwd || undefined, timeoutMs: 2000 }) + .then(r => String(r.stdout).trim()) + .catch(() => '') + const model = await $.session.model().catch(() => '') + + const quota = (kind: string, label: string, long: boolean): Segment['parts'] | undefined => { + const rl = usage?.rateLimits.find(r => r.kind === kind) + if (!rl) return undefined + const remain = Math.max(0, 100 - rl.percentUsed) + return meter(label, remain, remainColor(remain, warn, danger), style, resetText(rl.resetsAt, long)) + } + + const builders: Record Promise | Segment['parts'] | undefined> = { + character: () => [{ text: c.name, color: 'cyan' }], + model: () => (model ? [{ text: model }] : undefined), + dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined), + branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined), + '5h': () => quota('five_hour', '5h', false), + '7d': () => quota('seven_day', '7d', true), + ctx: () => { + const cx = usage?.context + if (!cx || cx.percent === undefined) return undefined + return meter('ctx', cx.percent, usedColor(cx.percent, warn, danger), style, ` ${fmtTokens(cx.tokens ?? 0)}/${fmtTokens(cx.window)}`) + }, + sys: async () => { + const s = await readSys($, holder, num('refreshSec', 10) * 1000) + if (!s) return undefined + return [ + { text: 'CPU ' }, { text: `${s.cpu}%`, color: usedColor(s.cpu, warn, danger) }, + { text: ' RAM ' }, { text: `${s.ram}%`, color: usedColor(s.ram, warn, danger) }, + { text: ' DSK ' }, { text: `${s.dsk}%`, color: usedColor(s.dsk, warn, danger) }, + ] + }, + } + + const segments: Segment[] = [] + for (const id of parseFields(str('fields', ''))) { + const parts = await builders[id]?.() + if (parts) segments.push({ id, parts }) + } + + const shown = layout(segments, e.props.bodyColumns) + const { Box, Text } = $.ui.resolve(e) + return ( + + + + {shown.map((s, i) => ( + + {i > 0 ? {SEPARATOR} : null} + {s.parts.map((p, j) => ( + {p.text} + ))} + + ))} + + + + ) + }) +} diff --git a/hooks/statusline.ts b/hooks/statusline.ts new file mode 100644 index 0000000..357aece --- /dev/null +++ b/hooks/statusline.ts @@ -0,0 +1,61 @@ +export type Color = 'green' | 'yellow' | 'red' | 'cyan' | undefined +export type Part = { text: string; color?: Color; dim?: boolean } +export type Segment = { id: string; parts: Part[] } + +export const BAR_LEN = 8 + +export const textWidth = (s: string): number => { + let w = 0 + for (const ch of s) { + const c = ch.codePointAt(0)! + w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1 + } + return w +} + +export const bar = (pct: number, style: 'block' | 'dot' = 'block'): string => { + const n = Math.round((Math.max(0, Math.min(100, pct)) / 100) * BAR_LEN) + const [on, off] = style === 'dot' ? ['●', '○'] : ['█', '░'] + return on.repeat(n) + off.repeat(BAR_LEN - n) +} + +// 使用率:越高越紅 +export const usedColor = (pct: number, warn: number, danger: number): Color => + pct >= danger ? 'red' : pct >= warn ? 'yellow' : 'green' + +// 剩餘:量表越少越紅 +export const remainColor = (remain: number, warn: number, danger: number): Color => + usedColor(100 - remain, warn, danger) + +export const FIELD_IDS = ['character', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const +// 窄終端機時整欄隱藏的順序 +export const HIDE_ORDER = ['sys', '7d', 'dir', 'branch'] as const + +export const parseFields = (s: string): string[] => { + const ids = s.split(',').map(x => x.trim()).filter(x => (FIELD_IDS as readonly string[]).includes(x)) + return ids.length ? [...new Set(ids)] : [...FIELD_IDS] +} + +const SEP = ' │ ' + +export const segmentWidth = (s: Segment): number => s.parts.reduce((n, p) => n + textWidth(p.text), 0) + +// 整欄隱藏,絕不截斷。 +export const layout = (segments: Segment[], columns: number): Segment[] => { + const list = [...segments] + const total = () => list.reduce((n, s) => n + segmentWidth(s), 0) + Math.max(0, list.length - 1) * textWidth(SEP) + for (const id of HIDE_ORDER) { + if (total() <= columns) break + const i = list.findIndex(s => s.id === id) + if (i >= 0) list.splice(i, 1) + } + return list +} + +export const SEPARATOR = SEP + +export const meter = (label: string, pct: number, color: Color, style: 'block' | 'dot', suffix: string): Segment['parts'] => [ + { text: `${label} ` }, + { text: bar(pct, style), color }, + { text: ` ${Math.round(pct)}%${suffix}` }, +] diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..57305c94c35cd9a11cdfdaaee861234cdca1c19a GIT binary patch literal 6199 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