# KChat

> ChatGPT-style chat on top of Claude Code: one flat session list, no workspace folder to pick, and a layout built for phones.

| | |
|---|---|
| Version | 1.0.0 |
| Category | AI & Agents |
| Permissions | network.remote, shell.exec |

# KChat

## Mục đích

Chat với AI theo kiểu **ChatGPT web**: một danh sách phiên phẳng bên trái, khung trả lời ở giữa,
ô nhập dạng viên thuốc ở dưới. **Không có khái niệm thư mục làm việc**, không tool use, không hỏi
quyền — chỉ hỏi và đáp.

Bên dưới vẫn là **Claude Code CLI** chạy qua Claude Agent SDK, cấu hình điểm kết nối giống hệt
`ai-chat` (hồ sơ biến môi trường trỏ `ANTHROPIC_BASE_URL` vào endpoint bất kỳ). Giao diện
**co giãn được tới khổ điện thoại**.

Đây là plugin viết mới, chép có chọn lọc từ `ai-chat` — không phải bản gọt bớt. Lý do: `ai-chat` lấy
danh sách chat từ các phiên Claude Code trên đĩa **gom theo thư mục dự án**, nên bỏ thư mục đi thì
mất luôn cấu trúc danh sách; còn `js/widget.js` của nó dài 10.196 dòng với dock nhiều khung, tool
card, prompt quyền, đính file — gọt thì tốn công hơn viết lại.

## So với `ai-chat`

| | ai-chat | KChat |
|---|---|---|
| Danh sách chat | cây thư mục → phiên | danh sách phẳng |
| Thư mục làm việc | người dùng chọn | ghim cứng `~/.kazuo/kchat` |
| Công cụ (Read/Bash/Edit…) | có, kèm hỏi quyền | **không có** (khoá 4 lớp) — trừ 3 công cụ lập lịch của chính KChat |
| Lập lịch hỏi định kỳ | qua `cron-job` (action `agent`) | form ⏰ **và** nói thẳng trong chat, qua `cron-job` |
| Nhiều khung / tách cửa sổ | có (Golden Layout) | không |
| Điện thoại | không dùng được | ngăn kéo trượt, nút cỡ ngón tay |
| Cấu hình endpoint | hồ sơ biến môi trường | **dùng chung của ai-chat** |
| `js/widget.js` | 10.196 dòng | ~640 dòng |

## File liên quan

- `manifest.json` — metadata, `defaultSettings`, `defaultWidgetConfig`
- `main/index.ts` — đăng ký IPC `plugin:kchat:*`, điều phối các chat đang mở
- `main/agent-session.ts` — bọc Agent SDK, **khoá toàn bộ công cụ**
- `main/sessions.ts` — thư mục ghim, danh sách/lịch sử/đổi tên/xoá phiên, dò CLI, tách mã model
- `main/schedules.ts` — dịch lịch dạng phẳng `When` ⇄ job của `cron-job`; chỉ đụng job của KChat
- `main/schedule-tools.ts` — 3 công cụ lập lịch cho model (MCP server chạy ngay trong tiến trình)
- `widget.html` / `css/widget.css` / `js/widget.js` — khung chat
- `settings.html` / `css/settings.css` / `js/settings.js` — trang cài đặt riêng
- `js/common.js` — helper dùng chung cho mọi trang
- `js/settings-form.js` — **form cài đặt viết một lần, cả hai trang cùng nạp**
- `locales/{en,vi}.json5` — mọi chuỗi hiển thị
- `test/` — kiểm thử jsdom (giao diện) + node (backend)

`package.json` chỉ khai `zod` (^4, bản Agent SDK yêu cầu) — thư viện mô tả tham số cho 3 công cụ lập
lịch. Agent SDK thì host cấp sẵn qua `ctx.modules.claudeAgent`. Thiếu `zod` thì KChat vẫn chat bình
thường, chỉ là model không tự đặt lịch được (form ⏰ vẫn chạy).

## Cách hoạt động

### Thư mục ghim làm cho danh sách phẳng

Agent SDK **bắt buộc** có một thư mục làm việc có thật, và xếp mỗi bản ghi phiên vào
`<config>/projects/<đường-dẫn-đã-làm-sạch>/`. `ai-chat` biến điều đó thành cây thư mục vì chat của nó
thực sự thuộc về dự án. KChat không có dự án nào, nên nó ghim **một** thư mục cho mọi cuộc trò
chuyện — thế là mọi bản ghi rơi vào cùng một rổ, và "liệt kê các cuộc trò chuyện" thành một danh sách
phẳng đúng kiểu ChatGPT, **không phải viết kho lưu trữ mới**.

Thư mục là `~/.kazuo/kchat` (của riêng plugin) để chat của KChat không hiện thành một workspace lạ
trỏ vào dự án thật của ai đó trong sidebar `ai-chat`.

### Khoá công cụ — bốn lớp

Một lớp là danh sách cấm chạy theo tên tool, mà tên tool thì thay đổi theo phiên bản. Nên
`agent-session.ts` dùng cả bốn:

1. `tools: []` — theo SDK, mảng tường minh là **toàn bộ** tập tool có sẵn, nên mảng rỗng là không có gì.
2. `disallowedTools: ['mcp__*']` — tool MCP không đi qua `tools`, phải có công tắc riêng.
3. `settingSources: []` — không nạp gì từ `~/.claude` của người dùng, nên hook/MCP server không lọt vào.
4. `canUseTool` từ chối mọi lời gọi, kèm lý do để model trả lời bằng lời thay vì cố gọi tiếp.

Vì vậy `permissionMode` để `'default'` — cờ `allowDangerouslySkipPermissions` mà `ai-chat` cần chỉ
tồn tại để tắt hộp thoại cho những tool KChat không có.

**Ngoại lệ duy nhất — công cụ lập lịch.** Khi chat bình thường, lớp 2 đổi từ "không có MCP nào" thành
"chỉ có server `kchat` của chính mình" (`mcpServers: { kchat }` + `allowedTools` đúng 3 tên), lớp 4
cho qua **đúng 3 tên** `mcp__kchat__{create,list,delete}_schedule` và vẫn từ chối mọi thứ khác
(Bash, tool của server khác…). Lớp 1 và 3 giữ nguyên. Lượt chạy **theo lịch** thì không có công cụ nào,
kể cả lập lịch — một lịch tự tạo được lịch là chỉ cách một câu lệnh để tự nhân bản.

### Lập lịch và thông báo

KChat **không có bộ lập lịch riêng** — dùng `cron-job`. Một lịch của KChat là một job `cron-job` có
action `{ type:'service', pluginId:'kchat', method:'runScheduled', payload }`; đến giờ `cron-job` gọi
`globalThis.__kazuoPluginServices.kchat.runScheduled(payload)`. Ngược lại, KChat tạo/liệt kê/xoá job
qua dịch vụ `__kazuoPluginServices['cron-job']` (vì kênh IPC chỉ gọi được từ giao diện, còn công cụ của
model chạy ở tiến trình nền). Chưa cài/bật `cron-job` → bảng ⏰ báo rõ, công cụ trả lỗi để model nói lại
với người dùng.

- **Hai cách tạo**: form trong bảng ⏰ (thanh trên cùng), hoặc nói trong chat ("9h sáng mai nhắc tôi…").
  Cả hai dùng chung một dạng phẳng `When`: `once` (`at` giờ địa phương `YYYY-MM-DDTHH:mm`), `daily`
  (`time`), `weekly` (`time` + `days` 0=CN…6=T7), `interval` (`everyMinutes` ≥ 5), `cron` (`expr` 5 trường).
  Model điền dạng này đúng hơn nhiều so với kiểu gốc của `cron-job` (epoch ms lồng trong object).
- **Model biết giờ hiện tại**: system prompt có giờ địa phương kèm múi giờ lúc khởi chạy; mỗi kết quả
  công cụ cũng trả `now`. Giờ đã qua bị từ chối kèm giờ hiện tại để model tự sửa.
- **Câu trả lời vào đúng cuộc trò chuyện** đã tạo lịch. Lịch tạo trong chat chưa bắt đầu (chưa có
  session) sẽ tạo một chat riêng mang tên lịch ở lần chạy đầu. Nối lịch → chat qua `scheduleThreads`
  (khoá `payload.key`), không sửa job trong `cron-job`, để khỏi đua với một lượt đang chạy.
- **Không chen ngang**: chat đó đang trả lời thì lượt theo lịch bị bỏ qua và ghi lỗi (hai tiến trình
  ghi một transcript sẽ làm rẽ nhánh lịch sử). Chat đang mở nhưng rảnh thì tiến trình CLI của nó bị
  huỷ, tin nhắn sau sẽ đọc lại lịch sử từ đĩa.
- **Thông báo**: trên máy tính là thông báo Windows (bấm vào → mở đúng chat, đưa cửa sổ KChat lên nếu
  đang tách riêng). Trên web dashboard (điện thoại) là toast của app qua `plugin:notification:show`
  kèm `activate` → `plugin:kchat:reveal-session`. Mỗi nền tảng một loại, để không báo hai lần.
- **Chưa đọc**: `sessionMeta[id].unread`; chấm xanh + chữ đậm trong danh sách, mở chat là xoá. Chat
  đang mở luôn coi là đã đọc (tránh một lượt làm mới danh sách trả lời muộn vẽ lại chấm).
- `runScheduled` **ném lỗi** khi thất bại để `cron-job` ghi lượt chạy là lỗi (nó coi mọi giá trị trả về
  là thành công). Tối đa 10 phút một lượt.

### Điểm kết nối — dùng chung với `ai-chat`

KChat **không bắt khai lại** những gì `ai-chat` đã có. Host cho phép một plugin đọc cài đặt của plugin
khác (`ctx.modules.pluginControl.getPluginSettingsFor`), nên KChat lấy thẳng danh sách điểm kết nối
của `ai-chat` và dùng nguyên. Bản của riêng KChat chỉ là **phương án dự phòng** cho máy không có
`ai-chat`, hoặc `ai-chat` chưa cấu hình gì.

Ba điểm cần nhớ:

- **Chỉ đọc, cố ý.** Hồ sơ của `ai-chat` mang các trường KChat không biết tới (`modelType`,
  `routerType`…). Ghi ngược một object hình dạng KChat sẽ **âm thầm xoá** chúng và làm hỏng giao diện
  của `ai-chat`. Nên màn Cài đặt của KChat hiện chúng dạng tóm tắt, sửa thì sửa bên `ai-chat`.
- **Token không bao giờ ra khỏi tiến trình nền.** Kênh `endpoints` chỉ trả `{id, name, models,
  configured}` — giao diện không cần `env`, nên không nhận `env`.
- **Một mục `default` trống không tính là đã cấu hình.** Đó là thứ `ai-chat` mặc định có sẵn; coi nó
  là cấu hình sẽ che mất danh sách mà chính KChat đang giữ.

Một "điểm kết nối" là một nhóm biến môi trường đưa cho CLI khi khởi chạy, gộp **đè lên** toàn bộ
`process.env` (binary vẫn cần `PATH`, `HOME`…). Thường là:

```
ANTHROPIC_BASE_URL   = http://127.0.0.1:20128
ANTHROPIC_AUTH_TOKEN = sk-...
```

- `CLAUDE_CONFIG_DIR` **luôn bị ghim** bởi plugin, không bao giờ thừa kế. Lý do: mọi plugin backend
  dùng chung một tiến trình Electron, và `ai-chat` có đổi tạm biến này khi quét phiên ở config dir
  khác — đọc đúng lúc đó là KChat sẽ khởi chạy CLI nhầm cài đặt.
- Nút "Lấy" đọc `/v1/models` **qua backend**, vì trang chạy trên origin `app://` bảo mật còn endpoint
  cục bộ là `http://` — Chromium chặn thẳng gọi từ renderer. Nhận cả hai dạng trả về: mảng trần hoặc
  `{data:[…]}`.
- Độ dài ngữ cảnh là **hậu tố riêng** (`[1m]`), giữ tách khỏi mã model và chỉ ghép đúng một lần lúc
  khởi chạy. Lưu dạng đã ghép sẽ làm chết các phiên cũ vào ngày endpoint thôi nhận `sonnet[1m]`.

### Cài đặt không bị chép hai lần

Host đã bỏ nút ⚙ ở góc widget, nên plugin phải tự mở được Cài đặt từ trong `widget.html`; nhưng
`settings.html` vẫn cần tồn tại cho Plugin Manager và cửa sổ pop-out. `ai-chat` giải quyết bằng cách
chép nguyên form sang hai nơi rồi lệch nhau dần.

KChat để toàn bộ form trong `js/settings-form.js`, và **cả hai trang cùng nạp file đó**. Hai trang vẫn
là hai sandbox riêng — mỗi trang có bản sao của mình — nhưng chỉ có một bản mã nguồn. Ràng buộc duy
nhất: id phần tử trong `widget.html` và `settings.html` phải trùng nhau.

### Giao diện điện thoại

- Có thẻ `viewport` (`ai-chat` không có, nên không dùng được trên điện thoại).
- `100dvh` chứ không phải `100vh` — trên điện thoại thanh địa chỉ tính vào `vh` và che mất ô nhập.
- Dưới 720px: danh sách phiên thành ngăn kéo trượt + lớp che, nút to lên 38px, chữ trong ô nhập 16px
  (dưới ngưỡng này iOS Safari tự phóng to trang khi bấm vào).
- `env(safe-area-inset-bottom)` để ô nhập không nằm dưới vạch home.
- Enter gửi tin trên máy tính; trên màn cảm ứng Enter xuống dòng (không có Shift để giữ).

## IPC channels (`plugin:kchat:`)

Mọi channel nhận plugin id làm tham số đầu để kiểm tra.

`state`, `endpoints`, `sessions`, `session-history` (sessionId), `session-title` (sessionId),
`session-rename` (sessionId, title), `session-delete` (sessionId), `open` (chatId, cfg),
`connect` (chatId), `close` (chatId), `send` (chatId, text), `interrupt` (chatId),
`set-model` (chatId, model, contextSuffix), `set-env-profile` (chatId, envProfileId),
`fetch-models` (url, headers), `ping`, `schedules` (sessionId) → `{ available, items }`,
`schedule-create` (sessionId, `{ prompt, name?, when }`), `schedule-delete` (id),
`schedule-toggle` (id, enabled), `mark-read` (sessionId).

`reveal-session` (`{ sessionId }`) là ngoại lệ **không** kiểm tra plugin id: toast của host gọi nó khi
người dùng bấm, và nó chỉ phát sự kiện `reveal_session` cho widget mở chat đó.

Sự kiện đi ra trên **một** channel `plugin:kchat:event`, mỗi sự kiện mang `chatId`: `connecting`,
`connected`, `session_started`, `models`, `stream_text`, `stream_thinking`, `assistant`, `tool_use`,
`context_window`, `result`, `done`, `stopped`, `error`. Ba sự kiện **không** gắn chat nào, mọi widget
đều xử lý: `scheduled_done` (`sessionId, title, ok, body`), `schedules_changed`, `reveal_session`.

Một lượt có gọi công cụ gồm **nhiều** sự kiện `assistant` (lời → gọi công cụ → lời tiếp). Widget cộng
dồn các phần (`base` + `live`), không để phần sau đè phần trước.

`connect` tách khỏi `open` để bấm xem một cuộc trò chuyện **không tốn gì** — tiến trình CLI chỉ khởi
động khi người dùng đặt con trỏ vào ô nhập hoặc bấm gửi.

`open` trả về `{ model, contextSuffix, envProfileId }` đã phân giải chứ không phải `true`: một cuộc
trò chuyện mở lại chạy bằng đúng model/điểm kết nối **nó** từng dùng, nên các ô chọn trên thanh trên
cùng phải lấy theo giá trị này.

## Giao diện

- **Thanh trên cùng**: ☰ (mở danh sách, chỉ ở khổ hẹp) · tiêu đề · **ô chọn điểm kết nối** · ô chọn
  model · **nút ⏰ lịch hẹn** · **nút bánh răng**. Hai ô chọn áp cho *cuộc trò chuyện đang mở*; đổi ô nào thì tiến trình CLI
  bị huỷ và tin nhắn kế tiếp mới khởi động lại (CLI chỉ đọc chúng lúc khởi chạy).
- **Lối vào Cài đặt có ở hai chỗ**: nút bánh răng trên thanh trên cùng và mục ở chân thanh bên. Phải
  có cái thứ nhất, vì bề rộng mặc định của widget (420px) **nằm dưới** ngưỡng ngăn kéo (720px) — thanh
  bên mặc định đã đóng, nên lối vào chỉ nằm trong đó là không nhìn thấy được.
- Dưới 720px: tiêu đề nhường chỗ cho hai ô chọn và nút bánh răng.

## Cài đặt mặc định

- `providers['claude-code'].envProfiles` — `[{ id:'default', name:'Default', env:{} }]`
  (chỉ dùng khi không mượn được của `ai-chat`)
- `contextLevels` — `[]` (mỗi dòng một mức, vd `1m`; cũng mượn theo điểm kết nối)
- `sessionDefaults` — `{ model:'', contextSuffix:'', envProfileId:'default' }`
- `claudeCli` — `{ binaryPath:'' }` (thứ tự dò: `KAZUO_CLAUDE_CLI` → ô này → `~/.local/bin/claude`)
- `systemPrompt` — `''`
- `thinkingDisplay` — `'collapsed'` (`'hidden'` **tắt luôn** phần suy nghĩ ở model, không chỉ ẩn đi)
- `sessionMeta` — model/endpoint đã dùng của từng phiên, để mở lại đúng như cũ; cờ `unread`
- `scheduleThreads` — `{ [payload.key]: sessionId }`, lịch nào trả lời vào chat nào

## Kiểm thử

```bash
npx tsc -p tsconfig.plugins.json          # phải chạy lại sau khi sửa main/*.ts
node --test plugins-library/kchat/test/widget.test.js            # 23 bài, jsdom
node --test plugins-library/kchat/test/widget-schedules.test.js  # 14 bài, jsdom — bảng ⏰, chưa đọc, toast
node --test plugins-library/kchat/test/backend.test.js           # 27 bài
node --test plugins-library/kchat/test/schedules.test.js         # 11 bài — When, khoá công cụ, runScheduled
```

Truyền **từng file**; `node --test plugins-library/kchat/test/` (thư mục) không chạy được trên Node 24.

`test/widget-harness.js` nạp **chính** `widget.html` và các script thật vào jsdom với một bridge giả,
nên bài kiểm tra soi đúng DOM mà widget dựng ra chứ không phải một bản dựng lại.

## Chạy thử

Lần đầu: mở Kazuo AI → Plugin Manager → plugin tự xuất hiện → Install → Enable.
Nếu chưa có Claude CLI, màn Cài đặt sẽ báo và cho điền đường dẫn.

### Đẩy bản sửa vào app đang chạy

App nạp plugin từ `<userData>/plugins/kchat/`, **không phải** từ `plugins-library/`. Script nạp lại
của dự án chỉ đồng bộ `main/`, nên phần giao diện phải tự chép:

```bash
# 1. chép giao diện (script reload KHÔNG làm bước này)
cp -r js css locales "$APPDATA/kazuo-ai/plugins/kchat/"
cp widget.html settings.html manifest.json icon.svg "$APPDATA/kazuo-ai/plugins/kchat/"

# 2. biên dịch main/, chép nó, rồi tắt/bật plugin
node scripts/session-lock/reload-plugin.js kchat    # chạy từ thư mục gốc dự án

# 3. nạp lại trang widget — bước 2 chỉ khởi động lại backend
```

Quên bước 1 hoặc 3 thì giao diện cũ vẫn hiện và bản sửa trông như không ăn.

**Cảnh báo:** `reload-plugin.js` chạy `tsc` cho **toàn bộ** thư viện plugin, nên nó sinh lại `main/*.js`
của mọi plugin có `.ts` đang sửa dở — làm bẩn `git status` của người khác. Script có in ra danh sách
đó, đọc kỹ.
