Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: ci

on:
pull_request:
push:
branches:
- main

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Type check
run: pnpm compile

- name: Run tests
run: pnpm test

- name: Run ESLint
run: pnpm lint

- name: Run Prettier
run: pnpm format:check
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
# pnpm が ~ を展開できない環境で cwd 相対に誤生成する store の残骸(プロジェクト外)
/~
.output
.wxt
dist
stats.html
*.log
dev/overlay-test.js
.DS_Store
.env
.env.*
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
save-exact=true
engine-strict=true
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.output
.wxt
dist
pnpm-lock.yaml
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all"
}
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,58 @@
# local-proofreader

完全ローカルの **Gemini Nano** で、Web 上の入力欄に打った日本語の誤字脱字・不自然さを検出し、
波線 + ツールチップで「直す/直さない」をユーザーに委ねる Chrome 拡張。クラウドに一切送らない、
プライバシー重視の日本語特化校正アシスタント。

## 方針(確定事項)

- **自動訂正はしない**。波線で提案し、クリックされて初めて該当範囲だけ置換する(Grammarly 方式)。
- **暗黙の fallback 禁止**。Nano 未対応/未取得時は黙って無効化せず、状態を明示する。
- **誤検知 > 見逃し**。高確信の指摘だけを出す。
- v1 対象は `<textarea>` と単純な `<input type=text>` のみ。contenteditable / Google Docs は対象外。

## 技術スタック

- ビルド: [WXT](https://wxt.dev)(Vite ベース、MV3)
- 言語: TypeScript(strict)
- AI: Chrome 組み込み AI。当面 **Prompt API(LanguageModel)** を共通インターフェース越しに利用し、
Proofreader API が日本語 stable になったら同インターフェースの別実装へ差し替える。

> **重要(環境)**: 日本語の Proofreader API は **Chrome 149+** が必要。手元が **Chrome 148** の場合は
> Prompt API を `chrome://flags/#prompt-api-for-gemini-nano` = **"Enabled multilingual"** で使う。
> 詳細・要件は [`phase0/README.md`](./phase0/README.md) を参照。

## セットアップ

```sh
pnpm install # postinstall で wxt prepare が走る
pnpm dev # 開発(Chrome を起動して拡張をロード)
pnpm build # 本番ビルド(.output/ に出力)
pnpm compile # 型チェック(tsc --noEmit)
pnpm lint # ESLint
pnpm format:check # Prettier チェック
```

## 進め方(フェーズ)

| Phase | 内容 | Nano 実機 |
| ----- | ---------------------------------------------------- | -------------- |
| **0** | コンソールで Nano 日本語校正精度を検証(go/no-go) | 要 |
| 1 | WXT 足場(background / content / popup) | 不要 |
| 2 | 入力検出 + IME/デバウンス監視 | 不要 |
| 3 | オーバーレイ mirror 描画(波線・座標・スクロール同期) | 不要(モックで) |
| 4 | AI 統合 + availability 状態機械 | 要 |
| 5 | 範囲置換 + キャッシュ + 高確信フィルタ | 要 |

**現状**: Phase 1/2 まで実装済み。Phase 0(`phase0/`)を実機で走らせ、精度を確認してから Phase 3 以降へ進む。

## ディレクトリ

```
entrypoints/ WXT エントリ(background / content / popup)
src/
ai/ AI クライアント抽象 + Prompt 実装 + 状態検出 + 正規化 + JSON Schema
input/ 校正対象の検出 + IME/デバウンス監視
state/ 状態 enum / 型定義
phase0/ Nano 精度検証ハーネス(コンソール用 + 手順)
```
54 changes: 54 additions & 0 deletions dev/overlay-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>overlay 位置精度テスト</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 40px;
max-width: 720px;
}
.hint {
color: #666;
font-size: 13px;
line-height: 1.6;
}
textarea {
width: 480px;
height: 160px;
font-size: 16px;
line-height: 1.7;
padding: 12px;
border: 2px solid #cccccc;
box-sizing: border-box;
}
input {
width: 480px;
font-size: 16px;
padding: 8px;
margin-top: 28px;
box-sizing: border-box;
}
h1 {
font-size: 18px;
}
</style>
</head>
<body>
<h1>Overlay 位置精度テスト(モック校正)</h1>
<p class="hint">
「たべした / くたさい / 難しいでした / いたしす / させていただくます」に波線が正確に重なるか。<br />
改行後・折り返し行・スクロール・リサイズでもズレないか。波線を hover/click → ツールチップ →
「適用」でその範囲だけ置換されるか。
</p>
<textarea>
明日までにリンゴをたべした。資料を確認してくたさい。この問題はとても難しいでした。後ほどご連絡させていただくます。本日中に報告いたしす。
改行のあとの行でも、たべした が正しい位置に波線が出るか確認する。
折り返しテスト:あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをんあいうえお。くたさい。</textarea
>
<input type="text" value="普通のテキスト入力。くたさい。" />
<script src="./overlay-test.js"></script>
</body>
</html>
26 changes: 26 additions & 0 deletions dev/overlay-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { OverlayRenderer } from '../src/overlay/renderer'
import { mockProofread } from '../src/proofread/mock'
import { applyCorrection } from '../src/apply/replace'
import type { ProofreadTarget } from '../src/input/detect'

// オーバーレイの位置精度を実ブラウザで確認するためのテストハーネス(Nano 不要)。
// esbuild で overlay-test.js にバンドルして overlay-test.html から読み込む。
function refresh(target: ProofreadTarget, renderer: OverlayRenderer): void {
const text = target.value
renderer.setData(text, text.trim().length > 0 ? mockProofread(text) : [])
}

function attach(target: ProofreadTarget): void {
const renderer = new OverlayRenderer(target, {
onApply: (correction) => {
applyCorrection(target, correction)
refresh(target, renderer)
},
})
target.addEventListener('input', () => refresh(target, renderer))
refresh(target, renderer)
}

document
.querySelectorAll<ProofreadTarget>('textarea, input[type="text"]')
.forEach((el) => attach(el))
7 changes: 7 additions & 0 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineBackground } from '#imports'

// Service Worker。組み込み AI は SW では呼べない(Proofreader/Rewriter は Worker 非対応、
// Prompt も document 文脈推奨)ため、ここでは状態調整・将来のメッセージ中継のみを担う。
export default defineBackground(() => {
// 現時点では常駐処理なし。content script / popup が各 document 文脈で AI を扱う。
})
140 changes: 140 additions & 0 deletions entrypoints/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { defineContentScript } from '#imports'
import { isProofreadTarget } from '../src/input/detect'
import type { ProofreadTarget } from '../src/input/detect'
import { InputWatcher } from '../src/input/watcher'
import { OverlayRenderer } from '../src/overlay/renderer'
import { applyCorrection } from '../src/apply/replace'
import { promptEngine } from '../src/ai/promptClient'
import { detectState } from '../src/ai/availability'

interface Managed {
target: ProofreadTarget
watcher: InputWatcher
renderer: OverlayRenderer
lastText: string | null // 最後に校正したテキスト(同一なら再校正をスキップ)
inFlight: boolean // 校正の二重起動を防ぐ
pending: boolean // 校正中に新しい入力が来た
seq: number // 古い非同期結果で新しい表示を上書きしないための連番
}

// Phase 4: 入力監視 → Nano 校正 → オーバーレイ波線 → ツールチップ(理由は遅延取得) → 範囲置換。
export default defineContentScript({
matches: ['*://*/*'],
runAt: 'document_idle',
main() {
let engineReady = false
let initPromise: Promise<boolean> | null = null

// モデルの準備を確認する。ダウンロードは popup(ユーザー操作)側で行う前提で、
// ここでは availability を確認し READY のときだけ create する。
// 非 READY のときは initPromise を残さない → popup で DL 後、再フォーカスで再判定される。
const ensureEngine = (): Promise<boolean> => {
if (engineReady) return Promise.resolve(true)
if (!initPromise) {
initPromise = (async () => {
const state = await detectState()
if (state.kind !== 'READY') {
console.warn(
`[local-proofreader] Nano 未準備 (${state.kind})。popup でモデルの状態を確認してください。`,
)
return false
}
await promptEngine.ensureReady()
engineReady = true
return true
})().finally(() => {
if (!engineReady) initPromise = null
})
}
return initPromise
}

const managed = new Map<Element, Managed>()

const proofread = async (m: Managed): Promise<void> => {
const text = m.target.value
if (text.trim().length === 0) {
m.lastText = text
m.renderer.setData(text, [])
return
}
if (text === m.lastText) return // 同一テキストは再校正しない
if (!(await ensureEngine())) return
if (m.inFlight) {
m.pending = true // 校正中なら完了後に最新で再実行
return
}
m.inFlight = true
const seq = ++m.seq
try {
const corrections = await promptEngine.proofread(text)
// 古い結果や入力変化後の結果で上書きしない。
if (seq === m.seq && m.target.value === text) {
m.lastText = text
m.renderer.setData(text, corrections)
}
} catch (e) {
console.warn('[local-proofreader] 校正に失敗:', e)
} finally {
m.inFlight = false
if (m.pending) {
m.pending = false
void proofread(m)
}
}
}

document.addEventListener('focusin', (e) => {
const el = e.target
if (!(el instanceof Element) || !isProofreadTarget(el)) return
if (managed.has(el)) return

const target = el
const renderer = new OverlayRenderer(target, {
// 自動訂正はしない。ユーザーがツールチップで「適用」したときだけ置換する。
onApply: (correction) => {
applyCorrection(target, correction)
// 置換成否に関わらず再校正して表示を最新化する(置換できない=入力変化時も含む)。
const m = managed.get(target)
if (m) void proofread(m)
},
onRequestReason: (correction) =>
promptEngine.explain(target.value, correction),
})
const m: Managed = {
target,
renderer,
watcher: new InputWatcher(target, {
onStableText: () => {
const entry = managed.get(target)
if (entry) void proofread(entry)
},
}),
lastText: null,
inFlight: false,
pending: false,
seq: 0,
}
managed.set(el, m)

// focus だけでは watcher は発火しないので、既存テキストを初回校正する。
void proofread(m)
})

// 要素が DOM から外れたら(SPA の画面遷移など)関連リソースを破棄する。
// WeakMap では解放タイミングを制御できないため Map + 切断検知で明示的に dispose する。
const cleanup = new MutationObserver(() => {
for (const [el, m] of managed) {
if (!el.isConnected) {
m.watcher.dispose()
m.renderer.dispose()
managed.delete(el)
}
}
})
cleanup.observe(document.documentElement, {
childList: true,
subtree: true,
})
},
})
18 changes: 18 additions & 0 deletions entrypoints/popup/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local Proofreader</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app">
<h1>Local Proofreader</h1>
<p id="status" class="status">確認中…</p>
<button id="download" type="button" hidden>モデルをダウンロード</button>
<p id="detail" class="detail"></p>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
Loading
Loading