claude-ai-office 0.23.7 → 0.25.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +210 -0
- package/README.md +17 -6
- package/bin/ai-office.js +9 -1
- package/lib/nodepath.js +34 -0
- package/lib/state.js +76 -8
- package/package.json +3 -3
- package/public/index.html +1 -0
- package/public/js/i18n.js +209 -0
- package/public/js/main.js +10 -2
- package/public/js/overview.js +4 -2
- package/public/js/scene/actors.js +4 -3
- package/public/js/scene/render.js +2 -1
- package/scripts/fetch-node.js +54 -0
- package/scripts/fetch-node.sh +2 -23
- package/scripts/install-hooks.js +3 -1
- package/scripts/make-tray-icon.js +57 -0
- package/scripts/update-homebrew.js +39 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# AI Office
|
|
2
|
+
|
|
3
|
+
[한국어](README.md) · English
|
|
4
|
+
|
|
5
|
+
An isometric office that shows what your Claude Code sessions are doing, in real time. Every Claude Code session in your terminal sends hook events; each session becomes a character that walks around the office. Reading a file sends them to the **Library**, running a command puts them in front of the **Server Room** racks, thinking keeps them at their desk **pondering**, and delegating work makes a subagent arrive by **Elevator**. Each git project gets its own floor, and every session of that project shares the floor. A session waiting for your answer raises a hand at **My Desk**, and you also get a browser notification, a chime and a tab-title badge.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
**macOS (Apple Silicon), as an app:** ⬇️ **[AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** (about 40 MB, Node bundled, always the latest), or `brew install --cask cozyuj/tap/ai-office`. Drag `AI Office.app` to Applications and **right-click → Open** the first time. See [Menu bar app](#menu-bar-app-tauri) below.
|
|
12
|
+
|
|
13
|
+
**Windows (x64), as an app:** ⬇️ **[AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)**. If SmartScreen warns, choose "More info → Run anyway".
|
|
14
|
+
|
|
15
|
+
**Everything else (Intel Mac · Linux · any OS with Node 18+), the web version:**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx claude-ai-office
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
(Straight from GitHub instead of npm: `npx github:cozyuj/AI-office`, same behavior.)
|
|
22
|
+
|
|
23
|
+
That one line (1) copies the app to `~/.ai-office/app`, (2) registers hooks in `~/.claude/settings.json`, then (3) starts the server and opens your browser. Run the same command again later to start the server; it also updates the installed copy when the package changed. Hooks apply to Claude Code sessions **started after** installation.
|
|
24
|
+
|
|
25
|
+
### `ai-office` subcommands
|
|
26
|
+
|
|
27
|
+
| Command | What it does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `npx claude-ai-office` | `up` = install + install-hooks + start + open browser |
|
|
30
|
+
| `… install [--force]` | Copy the app to `~/.ai-office/app` (a stable path independent of the npx cache) |
|
|
31
|
+
| `… install-hooks` | Register hooks in `~/.claude/settings.json` (idempotent, backs up the file) |
|
|
32
|
+
| `… uninstall-hooks` | Remove the hooks |
|
|
33
|
+
| `… start [--port N] [--no-open]` | Start only the server; if one is already running, open it |
|
|
34
|
+
| `… demo` | Fill the office with fake sessions (same as ⚙️ Settings → Demo) |
|
|
35
|
+
| `… doctor` | Check Node version, hooks, install, port, journal size and hook port |
|
|
36
|
+
| `… compact` | Compact the event journal right now (the server also does this hourly) |
|
|
37
|
+
|
|
38
|
+
### Hooks
|
|
39
|
+
|
|
40
|
+
`scripts/install-hooks.js` registers `node hooks/emit.js` for Claude Code's hook events (SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Notification, Stop, SubagentStart, SubagentStop, PreCompact). It needs only Node, no shell or curl (Windows included), and gives up silently within one second when the server is down, so Claude is never blocked.
|
|
41
|
+
|
|
42
|
+
### Port
|
|
43
|
+
|
|
44
|
+
The default port is `4777`. To change it, the server and the hooks must agree:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx claude-ai-office install-hooks --port 4800 # hooks → 4800
|
|
48
|
+
npx claude-ai-office start --port 4800 # server → 4800
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A mismatch shows a yellow banner in the UI and in the `hook port` line of `doctor`. From a clone:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/cozyuj/AI-office.git && cd AI-office
|
|
55
|
+
node bin/ai-office.js # or: npm run install-hooks && npm start
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## The screen
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
| Area | Contents |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **Left: Floors** | One floor per git project (repository root). Worktrees share the floor and show their branch (⎇). Opening a floor lists its sessions with `waiting` and `viewing` badges. 🎨 on the selected floor picks that floor's interior preset (Warm Wood · Cool Gray · Mint · Lavender · Sand · Pastel; assigned automatically by default). "Follow the latest session" (`A`) moves the selection to the most recently active session; when it is off, the last floor and session you looked at are restored next time |
|
|
65
|
+
| **Center: Office** | Drag to pan, wheel or buttons to zoom, `⟲` to reset, pinch on touch screens. Click a character to select its session; hover for a tooltip (current action, target file, prompt, branch, path). 📍 **Follow** (`F`) keeps the camera on the selected session's Claude. 🏢 **Building view** (`O`) appears when there are two or more floors |
|
|
66
|
+
| **Right panel** | Session card (project path · session id · character name · copy the resume command · 🗑 dismiss ended sessions), people in the office (room · speech bubble · elapsed time), activity log (search with `/`, selected session only), legend (click the title to collapse) |
|
|
67
|
+
|
|
68
|
+
**Resume** on the session card copies `cd "<project folder>" && claude --resume <session id>` to the clipboard, so you can continue a session from the terminal. Sessions idle for two hours are retired automatically; **🗑 Dismiss** removes a finished session from the list right away.
|
|
69
|
+
|
|
70
|
+
### Who goes where
|
|
71
|
+
|
|
72
|
+
| Tool | Room | Speech bubble |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| Read, Grep, Glob | 📖 Library | `reading server.js`, `searching "TODO"` |
|
|
75
|
+
| Edit, Write | ✏️ Dev Room | `editing app.js` |
|
|
76
|
+
| Bash | 🖥️ Server Room | `running npm test` |
|
|
77
|
+
| WebSearch, WebFetch, MCP tools | 🌐 Research Room | `searching "tauri vs electron"` |
|
|
78
|
+
| Agent, TodoWrite, Skill, Plan | 🤝 Meeting Room | `delegating: find API endpoints` |
|
|
79
|
+
| AskUserQuestion, permission prompts | 🙋 My Desk | `I have a question`, `need permission` |
|
|
80
|
+
| Stop (end of turn) | ☕ Lounge · Café · Terrace (one rest room per session) | `resting`, `having coffee`, `getting some air` |
|
|
81
|
+
| SubagentStart / Stop | 🛗 Elevator | arriving / leaving |
|
|
82
|
+
|
|
83
|
+
Each session gets one rest room, chosen from a hash of its session id, so every browser sees the same session in the same room. The café (a glass-walled room with window seats) and the terrace (deck, parasols, benches) are places to rest, not rooms tied to a tool. With **Liveliness** on (⚙️ Settings), resting characters wander between spots in their room (counter, window, table, parasol, bench, railing, sofa, vending machine) every 10–30 seconds with bubbles like `☕ a latte`, `🪟 looking outside`, `🌤️ nice breeze`; with it off they stay put.
|
|
84
|
+
|
|
85
|
+
### A different office on every floor
|
|
86
|
+
|
|
87
|
+
Each floor (= project) is assigned an **interior preset** automatically (hash of the floor path, avoiding its neighbours). A preset sets not only the palette but the **layout**: the nine work rooms (Library, Dev Room, Research Room, Meeting Room, Lounge, Server Room, My Desk, Lobby, Elevator) sit in different places, and the two rest facilities change.
|
|
88
|
+
|
|
89
|
+
| Preset | Facilities |
|
|
90
|
+
|---|---|
|
|
91
|
+
| 🪵 Warm Wood · 🍬 Pastel | Café · Terrace |
|
|
92
|
+
| 🩶 Cool Gray | Seminar Room · Studio |
|
|
93
|
+
| 🌿 Mint | Gym · Rooftop Garden |
|
|
94
|
+
| 💜 Lavender | Book Lounge · Game Room |
|
|
95
|
+
| 🏜️ Sand | Canteen · Nap Room |
|
|
96
|
+
|
|
97
|
+
A session's rest room (café or terrace) becomes that floor's facility: on a Mint floor the character goes to the Gym (`🏃 working out`) and the log reads `✅ … → 🏃 Gym`. Press **🎨** on the selected floor to pick a preset by hand; the choice is stored on the server (`~/.ai-office/floor-styles.json`) so every browser sees the same floor (`Auto` restores the automatic choice). Building view shows the different layouts too.
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
101
|
+
Subagents wear blue hard hats and are named after the `description` given to the Agent tool. You (`me`) sit at My Desk, and Claude comes over when it has a question or needs permission.
|
|
102
|
+
|
|
103
|
+
## Settings and shortcuts
|
|
104
|
+
|
|
105
|
+
Open **⚙️ Settings** in the header. Everything here is stored in this browser only (localStorage).
|
|
106
|
+
|
|
107
|
+
| Setting | Meaning | Key |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| Bubbles | Show speech bubbles for **all** sessions or only the **selected** one | `B` |
|
|
110
|
+
| Browser notifications | OS notification when Claude asks a question or waits for permission. Asks for browser permission the first time | |
|
|
111
|
+
| Waiting chime | A short sound when a session starts waiting for you. Click once so the browser allows audio | `S` |
|
|
112
|
+
| Sound scope | **Waiting only** (high two-tone) or **also failures and session end** (low two-tone) | |
|
|
113
|
+
| Liveliness | Resting characters wander and idle animations | |
|
|
114
|
+
| Theme | Light / dark | `T` |
|
|
115
|
+
| Demo | Four fake sessions on separate `(demo)` floors | `D` |
|
|
116
|
+
| Character looks | Opens the customizer (below) | |
|
|
117
|
+
| Daylight | Window, terrace and room lighting follow the real clock: morning (06–10), day (10–17), evening (17–20), night (20–06). Can be fixed to one period or turned off | |
|
|
118
|
+
| Floor interior | Interior preset of the floor you are viewing | |
|
|
119
|
+
| Language | 한국어 / English | |
|
|
120
|
+
| Shortcuts | Keyboard shortcut panel | `?` |
|
|
121
|
+
|
|
122
|
+
Shortcuts work when no input field has focus.
|
|
123
|
+
|
|
124
|
+
| Key | Action |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `0` – `9` | Go to floor number (7 → 7F; for 12F press `1` then `2`) |
|
|
127
|
+
| `←` `→` (or `↑` `↓`) | Previous / next floor |
|
|
128
|
+
| `F` | 📍 Follow the selected session's Claude |
|
|
129
|
+
| `A` | Follow the latest session automatically |
|
|
130
|
+
| `O` | 🏢 Building view |
|
|
131
|
+
| `D` | Demo on/off |
|
|
132
|
+
| `B` | Bubbles: all / selected |
|
|
133
|
+
| `S` | Waiting chime |
|
|
134
|
+
| `T` | Light / dark theme |
|
|
135
|
+
| `/` | Focus the log search |
|
|
136
|
+
| `Esc` | Close shortcut panel · customizer · settings · building view → clear search → stop following (one step at a time) |
|
|
137
|
+
| `?` | Shortcut panel |
|
|
138
|
+
|
|
139
|
+
## Character looks and names
|
|
140
|
+
|
|
141
|
+

|
|
142
|
+
|
|
143
|
+
⚙️ Settings → **Character looks** customizes four targets separately: **me / default Claude / default subagent / the Claude of the session you are viewing**.
|
|
144
|
+
|
|
145
|
+
- **10 presets**: Developer · Suit · Researcher · Casual · Artist · Wizard · King · Robot · Cat · Bear. Pick one, then tweak the parts.
|
|
146
|
+
- **4 body types**: human · robot · cat · bear. The skin (fur, metal) palette follows the body.
|
|
147
|
+
- **Parts**: skin, hair (12 styles + color), top (8 styles + color; `auto` uses the session color), bottom (pants · shorts · skirt), shoes, glasses (round · square · sunglasses · goggles · monocle), hats (cap · beanie · beret · headband · bucket hat · top hat · wizard · crown …), extras (headphones · scarf · badge · earring · bow tie · beard · mask …).
|
|
148
|
+
- 🎲 random, ↺ reset, 🗑 delete the saved look. Saved in this browser only.
|
|
149
|
+
|
|
150
|
+
**Names per session**: every session's character gets an automatic nickname (Alex, Sam, Max, Leo, Mia, Zoe, …). Rename it in the "Character" row of the session card or in the customizer (🎲 draws another nickname; clearing the field goes back to automatic). The log and tooltips use that name. The server still calls it `Claude`; only the screen changes.
|
|
151
|
+
|
|
152
|
+
## Building view
|
|
153
|
+
|
|
154
|
+

|
|
155
|
+
|
|
156
|
+
With two or more floors, 🏢 **Building view** (`O`) appears in the header. Floors are stacked like a building, each with a mini office, character dots and the session count and status (`waiting` / `working` / `thinking` / `quiet`). The interior preset of each floor shows in its mini office. Click a floor to enter it; `Esc` or the same button closes the view.
|
|
157
|
+
|
|
158
|
+
## Menu bar app (Tauri)
|
|
159
|
+
|
|
160
|
+
A resident app that shows the web UI in a window. It starts the Node server itself (or reuses one that is already running) and lives in the menu bar / system tray with Open, Hide, Always on top, Reinstall hooks, Check for updates and Quit. When sessions are waiting for you, the menu bar shows `🙋 n`. Closing the window hides it; Quit stops the server too. Node is bundled: on first launch the app copies the server files to `~/.ai-office/app` and the Node binary to `~/.ai-office/node`, then registers the hooks, so moving or deleting the app does not break them.
|
|
161
|
+
|
|
162
|
+
**Updates**: the app checks for a new version 10 seconds after launch and every 6 hours, downloads it in the background, then shows ⬆ in the menu bar and enables "Apply update (restart)". Releases are built by GitHub Actions for macOS (Apple Silicon) and Windows (x64) and signed for the updater.
|
|
163
|
+
|
|
164
|
+
> **Download**: [macOS AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg) · [Windows AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe) (always the latest; older versions in [Releases](https://github.com/cozyuj/AI-office/releases)). The macOS app is not notarized: **right-click → Open** the first time; on macOS 15+ use System Settings → Privacy & Security → **Open Anyway**, and if it still says "damaged", run `xattr -dr com.apple.quarantine "/Applications/AI Office.app"` once. To build it yourself:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npm run app:dev # dev run (Rust toolchain + Xcode CLT / MSVC build tools, first build takes minutes)
|
|
168
|
+
npm run app:build # .app / .dmg / .exe under app/src-tauri/target/release/bundle/
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`scripts/fetch-node.js` downloads the Node LTS binary and bundles it as a sidecar; the server files are app resources. A dev run without the bundle falls back to the system `node` and the repository's `server.js`.
|
|
172
|
+
|
|
173
|
+
## Architecture
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
Claude Code session ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js ─SSE /events─▶ public/ (canvas)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- `server.js` receives hooks, applies the state machine, relays SSE, serves static files, starts/stops the demo (`/api/demo/start|stop`), dismisses sessions (`DELETE /api/sessions/:id`)
|
|
180
|
+
- `lib/normalize.js` slims hook payloads and maps tools to rooms and bubbles
|
|
181
|
+
- `lib/state.js` floor/session/character state machine, `lib/project.js` cwd → git repository, branch, worktree
|
|
182
|
+
- `lib/journal.js` JSONL event journal (`~/.ai-office/events.jsonl`), replayed on start, compacted hourly
|
|
183
|
+
- `public/js/scene/*` isometric map, furniture, characters (`avatar.js`), A*, camera, daylight (`daylight.js`), floor styles (`floorStyle.js`)
|
|
184
|
+
- `public/js/overview.js` building view, `customize.js` customizer, `names.js` per-session names, `panels.js` side panels, `i18n.js` language
|
|
185
|
+
- `bin/ai-office.js` CLI, `scripts/install-hooks.js` hook registration, `scripts/demo.js` demo
|
|
186
|
+
|
|
187
|
+
Zero dependencies, no bundler. Design notes: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) (Korean), technical overview: [docs/AI-Office-소개.html](docs/AI-Office-소개.html) (Korean).
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm test # node --test (state machine · pathfinding · looks · names · CLI · server · smoke …)
|
|
193
|
+
AI_OFFICE_PORT=4799 npm start # another port (hooks must be registered for the same port)
|
|
194
|
+
npm run demo # push fake sessions into a running server (DEMO_SPEED=3 for faster)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Versioning: bug fixes and docs bump patch, features bump minor, breaking changes bump major. One command updates all four files (package.json, app/package.json, tauri.conf.json, Cargo.toml):
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm run bump:patch # 0.24.0 → 0.24.1
|
|
201
|
+
npm run bump:minor # 0.24.0 → 0.25.0
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Releases: push a tag `vX.Y.Z` and GitHub Actions builds the macOS and Windows apps, uploads them to the release with the updater manifest and stable-named copies, then installs and launches the Windows build on a runner to check the server and hooks. Tests run on Ubuntu, Windows and macOS on every push and pull request.
|
|
205
|
+
|
|
206
|
+
Workflow: create a GitHub issue named `[FE-30]`, `[BE-13]`, `[APP-03]` (FE/BE/APP), branch from `main` as `feature/FE-30`, and report results, tests and screenshots as issue comments.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# AI Office
|
|
2
2
|
|
|
3
|
+
한국어 · [English](README.en.md)
|
|
4
|
+
|
|
3
5
|
Claude Code 가 터미널에서 뭘 하고 있는지 한눈에 보려고 차린 사무실. 내 터미널의 Claude Code 세션들이 훅(hook) 이벤트를 보내면, 세션마다 캐릭터 한 명이 아이소메트릭 사무실에서 실시간으로 움직인다. 파일을 읽을 땐 **자료실** 가서 책을 펴고, 명령을 돌릴 땐 **서버실** 랙 앞에 서 있고, 막히면 그 자리에서 턱 괴고 **고민 중**, 일을 나눠 맡기면 서브에이전트가 **엘리베이터** 타고 출근한다. git 프로젝트마다 층이 하나씩 생기고, 한 층에 그 프로젝트의 세션 전부가 보인다. 답을 기다리는 세션은 **내 자리** 에서 손을 들고, 브라우저 알림·소리·탭 제목으로도 알려 준다.
|
|
4
6
|
|
|
5
7
|

|
|
6
8
|
|
|
7
9
|
## 설치
|
|
8
10
|
|
|
9
|
-
**맥(Apple Silicon)은 앱으로:** ⬇️ **[AI
|
|
11
|
+
**맥(Apple Silicon)은 앱으로:** ⬇️ **[AI-Office-mac-arm64.dmg 받기](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** (약 40MB, Node 내장, 항상 최신 버전). 받은 뒤 `AI Office.app` 을 응용 프로그램 폴더에 넣고 처음 한 번 **우클릭 → 열기**. Homebrew 로는 `brew install --cask cozyuj/tap/ai-office`. 자세한 건 아래 [메뉴바 앱](#메뉴바-앱-tauri).
|
|
12
|
+
|
|
13
|
+
**Windows(x64)도 앱으로:** ⬇️ **[AI-Office-windows-x64-setup.exe 받기](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)**. SmartScreen 경고가 뜨면 "추가 정보 → 실행".
|
|
10
14
|
|
|
11
|
-
**그 외(Intel 맥 ·
|
|
15
|
+
**그 외(Intel 맥 · Linux 등)는 웹 버전으로:** Node 18 이상만 있으면 공통 한 줄:
|
|
12
16
|
|
|
13
17
|
```bash
|
|
14
18
|
npx claude-ai-office
|
|
@@ -31,6 +35,7 @@ npx claude-ai-office
|
|
|
31
35
|
| `… start [--port N] [--no-open]` | 서버만 기동. 이미 떠 있으면 그 주소를 연다 |
|
|
32
36
|
| `… demo` | 가짜 세션으로 사무실을 북적이게 (화면의 ⚙️ 설정 → 데모와 같음) |
|
|
33
37
|
| `… doctor` | Node 버전·훅 등록 수·설치본·포트·저널·훅 포트 점검 |
|
|
38
|
+
| `… compact` | 이벤트 저널 즉시 압축 (서버도 매시간 자동) |
|
|
34
39
|
|
|
35
40
|
### 훅
|
|
36
41
|
|
|
@@ -111,6 +116,8 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
|
|
|
111
116
|
| 테마 | 라이트 / 다크 | `T` |
|
|
112
117
|
| 데모 | 가짜 세션 4개를 띄워 사무실을 북적이게. 실제 세션과 별도 층이고 `(데모)` 가 붙는다 | `D` |
|
|
113
118
|
| 캐릭터 외형 | 외형 모달 열기 (아래 참고) | |
|
|
119
|
+
| 층 인테리어 | 지금 보는 층의 인테리어 프리셋 (층 행의 🎨 와 같음) | |
|
|
120
|
+
| 언어 | 한국어 / English. 화면 문구·캔버스 글자·알림이 바뀐다. 세션 제목·프롬프트·Claude 메시지는 쓴 그대로 | |
|
|
114
121
|
| 시간대 조명 | 실제 시각에 따라 창문·테라스·실내 빛이 아침(06~10)·낮(10~17)·저녁(17~20)·밤(20~06)으로 바뀐다. `자동` 외에 `끔` 이나 시간대 하나로 고정 가능 | |
|
|
115
122
|
| 단축키 | 키보드 단축키 표 패널 | `?` |
|
|
116
123
|
|
|
@@ -150,18 +157,20 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
|
|
|
150
157
|
|
|
151
158
|
층이 둘 이상이면 헤더에 🏢 **전체 보기**(`O`)가 나타납니다. 층들을 건물처럼 세로로 쌓아, 층마다 미니 사무실 위에 캐릭터 점과 세션 수·상태(`답 기다림` / `작업 중` / `생각 중` / `조용함`)를 보여 줍니다. 층마다 고른 인테리어 스타일이 미니 사무실에도 반영됩니다. 층을 클릭하면 그 층 사무실로 들어갑니다. `Esc` 또는 같은 버튼으로 닫습니다.
|
|
152
159
|
|
|
153
|
-
##
|
|
160
|
+
## 메뉴바 앱 (Tauri)
|
|
154
161
|
|
|
155
|
-
웹 버전을 그대로 창에 담은 상주
|
|
162
|
+
웹 버전을 그대로 창에 담은 상주 앱입니다(맥 메뉴바 · Windows 트레이). 앱이 Node 서버를 직접 띄우고(이미 떠 있으면 재사용) 아이콘 메뉴에서 열기/숨기기/항상 위/훅 다시 설치/업데이트 확인/종료를 합니다. 답을 기다리는 세션이 있으면 메뉴바에 `🙋 n` 이 뜹니다. 창을 닫아도 메뉴바에 남고, 종료하면 서버도 함께 내려갑니다(앱이 강제 종료돼도 서버가 스스로 끝납니다). 앱에는 Node 가 내장되어 있고, 첫 실행 때 서버 파일을 `~/.ai-office/app` 에, Node 를 `~/.ai-office/node` 에 복사하고 그 경로로 훅을 등록하므로 앱을 옮기거나 지워도 훅이 깨지지 않습니다.
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
**자동 업데이트**: 앱이 시작 10초 뒤와 6시간마다 새 버전을 확인해 백그라운드로 받아 두고, 메뉴바에 ⬆ 를 띄우며 메뉴의 "업데이트 적용 (다시 시작)" 이 켜집니다. 릴리스는 태그를 올리면 GitHub Actions 가 맥(Apple Silicon)·Windows(x64) 로 빌드하고 서명해 올립니다.
|
|
165
|
+
|
|
166
|
+
> **다운로드**: ⬇️ **[맥 AI-Office-mac-arm64.dmg](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-mac-arm64.dmg)** · **[Windows AI-Office-windows-x64-setup.exe](https://github.com/cozyuj/AI-office/releases/latest/download/AI-Office-windows-x64-setup.exe)** (항상 최신) · 이전 버전은 [Releases](https://github.com/cozyuj/AI-office/releases). DMG 를 열어 `AI Office.app` 을 응용 프로그램 폴더에 넣으세요. 코드 서명이 없어 처음엔 "확인되지 않은 개발자" 경고가 뜨니 앱을 **우클릭 → 열기** 로 한 번 허용하고, macOS 15 이상에서 안 열리면 시스템 설정 → 개인정보 보호 및 보안 → **그래도 열기**, 그래도 "손상됨" 이라고 나오면 터미널에서 `xattr -dr com.apple.quarantine "/Applications/AI Office.app"` 를 한 번 실행하세요. Intel 맥·Linux 는 위의 `npx claude-ai-office` 한 줄로 웹 버전을 쓰세요. 직접 빌드하려면:
|
|
158
167
|
|
|
159
168
|
```bash
|
|
160
169
|
npm run app:dev # 개발 실행 (Rust 툴체인 + Xcode CLT 필요, 첫 빌드 수 분)
|
|
161
170
|
npm run app:build # app/src-tauri/target/release/bundle/ 에 .app / .dmg
|
|
162
171
|
```
|
|
163
172
|
|
|
164
|
-
빌드 시 `scripts/fetch-node.
|
|
173
|
+
빌드 시 `scripts/fetch-node.js` 가 Node LTS 바이너리(맥·리눅스·Windows)를 내려받아 사이드카로 번들하고, 서버 파일은 앱 리소스로 포함됩니다. 개발 실행(레포에서)은 번들이 없으면 시스템 `node` 와 레포의 `server.js` 를 씁니다.
|
|
165
174
|
|
|
166
175
|
## 구조
|
|
167
176
|
|
|
@@ -194,4 +203,6 @@ npm run bump:patch # 0.19.0 → 0.19.1
|
|
|
194
203
|
npm run bump:minor # 0.19.0 → 0.20.0
|
|
195
204
|
```
|
|
196
205
|
|
|
206
|
+
릴리스: `git tag vX.Y.Z && git push origin vX.Y.Z` 를 올리면 GitHub Actions 가 맥·Windows 앱을 빌드해 Release 에 올리고(updater 용 `latest.json` 포함), Windows 러너에서 설치 → 실행 → 훅까지 확인합니다. 테스트는 push·PR 마다 Ubuntu·Windows·macOS 에서 돕니다.
|
|
207
|
+
|
|
197
208
|
작업 흐름: GitHub 이슈를 `[FE-30]`, `[BE-13]`, `[APP-03]` 처럼 FE/BE/APP 으로 나눠 만들고, `main` 에서 `feature/FE-30` 식 브랜치를 분리해 작업한 뒤 결과·테스트·캡처를 이슈 댓글로 남긴다.
|
package/bin/ai-office.js
CHANGED
|
@@ -17,6 +17,7 @@ const os = require('os');
|
|
|
17
17
|
const net = require('net');
|
|
18
18
|
const { spawn, execFileSync } = require('child_process');
|
|
19
19
|
const { readHookPort } = require('../lib/hookport');
|
|
20
|
+
const { isVersionedNodePath } = require('../lib/nodepath');
|
|
20
21
|
const crypto = require('crypto');
|
|
21
22
|
|
|
22
23
|
const SRC = path.join(__dirname, '..');
|
|
@@ -65,6 +66,11 @@ function fingerprint(dir) {
|
|
|
65
66
|
const runningFromSource = () => path.resolve(SRC) !== path.resolve(APP) && fs.existsSync(path.join(SRC, '.git'));
|
|
66
67
|
function install({ force = false } = {}) {
|
|
67
68
|
if (path.resolve(SRC) === path.resolve(APP)) return APP; // 이미 설치 경로에서 실행 중
|
|
69
|
+
// 데스크톱 앱이 관리하는 설치본(.app-version 스탬프)은 덮어쓰지 않는다. 덮으면 다음 앱 실행 때 앱 버전으로 되돌아가 서로 덮어쓴다
|
|
70
|
+
if (!force && fs.existsSync(path.join(APP, '.app-version'))) {
|
|
71
|
+
log(`AI Office 앱이 ${APP} 를 관리하고 있어 그대로 둡니다 (앱 ${fs.readFileSync(path.join(APP, '.app-version'), 'utf8').trim()}, 덮어쓰려면 --force)`);
|
|
72
|
+
return APP;
|
|
73
|
+
}
|
|
68
74
|
const have = installedVersion();
|
|
69
75
|
const fp = fingerprint(SRC);
|
|
70
76
|
let installedFp = null;
|
|
@@ -125,7 +131,7 @@ async function doctor() {
|
|
|
125
131
|
log(`node ${process.version} ${major >= 18 ? '✓' : '✗ 18 이상 필요'} (${process.execPath})`);
|
|
126
132
|
log(`platform ${process.platform} ${os.release()}`);
|
|
127
133
|
const settings = process.env.CLAUDE_SETTINGS || path.join(os.homedir(), '.claude', 'settings.json');
|
|
128
|
-
let hooks = 0, stale = 0;
|
|
134
|
+
let hooks = 0, stale = 0, versioned = 0;
|
|
129
135
|
try {
|
|
130
136
|
const h = JSON.parse(fs.readFileSync(settings, 'utf8')).hooks || {};
|
|
131
137
|
for (const ev of Object.values(h)) for (const g of ev) for (const x of g.hooks || []) {
|
|
@@ -135,9 +141,11 @@ async function doctor() {
|
|
|
135
141
|
if (m && !fs.existsSync(m[1])) stale++;
|
|
136
142
|
const n = /^"([^"]+)"/.exec(x.command); // 앞의 node 경로(앱이 등록한 내장 node 등)도 있어야 한다
|
|
137
143
|
if (n && n[1] !== 'node' && !fs.existsSync(n[1])) stale++;
|
|
144
|
+
if (n && isVersionedNodePath(n[1])) versioned++;
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
log(`settings ${settings} ✓ 훅 ${hooks}개 등록${stale ? `, ${stale}개는 node 또는 emit.js 파일 없음 → ai-office install-hooks` : ''}`);
|
|
148
|
+
if (versioned) log(` ⚠ 훅 ${versioned}개가 Node 버전 폴더 경로라 brew upgrade 뒤 깨질 수 있음 → ai-office install-hooks`);
|
|
141
149
|
} catch { log(`settings ${settings} ✗ 읽을 수 없음`); }
|
|
142
150
|
log(`app ${installedVersion() ? `${installedVersion()} @ ${APP} ✓` : '미설치 (ai-office install)'}`);
|
|
143
151
|
log(`port ${PORT} ${(await portInUse(PORT)) ? '사용 중 (서버 떠 있음)' : '비어 있음'}`);
|
package/lib/nodepath.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 훅 명령에 넣을 Node 경로 (BE-18). Node 를 업그레이드해도 깨지지 않는 경로를 고른다.
|
|
3
|
+
// 1) ~/.ai-office/node(.exe): 데스크톱 앱이 복사해 둔 Node. 앱과 CLI 가 같은 훅 명령을 쓰게 된다
|
|
4
|
+
// 2) Homebrew 의 버전 폴더(/opt/homebrew/Cellar/node/26.12.0/bin/node) → 버전 없는 opt 링크(/opt/homebrew/opt/node/bin/node)
|
|
5
|
+
// 3) 그 밖에는 실행 중인 Node 그대로
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const CELLAR = /^(.*)[\\/]Cellar[\\/](node(?:@\d+)?)[\\/][^\\/]+[\\/]bin[\\/]node$/;
|
|
11
|
+
|
|
12
|
+
function isFile(p, fsImpl) { try { return fsImpl.statSync(p).isFile(); } catch { return false; } }
|
|
13
|
+
|
|
14
|
+
/** Homebrew Cellar 의 버전 폴더 경로인가 (업그레이드·cleanup 때 사라진다) */
|
|
15
|
+
function isVersionedNodePath(p) { return CELLAR.test(String(p || '')); }
|
|
16
|
+
|
|
17
|
+
function stableNodePath({
|
|
18
|
+
execPath = process.execPath,
|
|
19
|
+
home = process.env.AI_OFFICE_HOME || path.join(os.homedir(), '.ai-office'),
|
|
20
|
+
platform = process.platform,
|
|
21
|
+
fsImpl = fs,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const appNode = path.join(home, platform === 'win32' ? 'node.exe' : 'node');
|
|
24
|
+
if (isFile(appNode, fsImpl)) return appNode;
|
|
25
|
+
const m = CELLAR.exec(execPath);
|
|
26
|
+
if (m) {
|
|
27
|
+
const sep = execPath.includes('\\') && !execPath.includes('/') ? '\\' : '/'; // Homebrew 경로는 POSIX, 그 구분자를 그대로 쓴다 (Windows 의 path.join 은 \\ 를 쓴다)
|
|
28
|
+
const opt = [m[1], 'opt', m[2], 'bin', 'node'].join(sep);
|
|
29
|
+
if (isFile(opt, fsImpl)) return opt;
|
|
30
|
+
}
|
|
31
|
+
return execPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { stableNodePath, isVersionedNodePath };
|
package/lib/state.js
CHANGED
|
@@ -40,7 +40,50 @@ function defaultStat(file) {
|
|
|
40
40
|
try { return { mtimeMs: fs.statSync(file).mtimeMs }; } catch { return null; }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const FIRST_PROMPT_HEAD_BYTES = 1024 * 1024;
|
|
44
|
+
/** transcript 앞부분에서 첫 사용자 메시지(실제 요청)를 읽는다. 이미지가 붙은 첫 줄은 수백 KB 라 잘려도 텍스트만 뽑는다. 없으면 null */
|
|
45
|
+
function defaultFirstPrompt(file) {
|
|
46
|
+
let fd;
|
|
47
|
+
try {
|
|
48
|
+
fd = fs.openSync(file, 'r');
|
|
49
|
+
const buf = Buffer.alloc(FIRST_PROMPT_HEAD_BYTES);
|
|
50
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
51
|
+
for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
|
|
52
|
+
if (!line.includes('"type":"user"') || line.includes('"isMeta":true')) continue;
|
|
53
|
+
let text = null;
|
|
54
|
+
try {
|
|
55
|
+
const c = JSON.parse(line).message?.content;
|
|
56
|
+
text = typeof c === 'string' ? c : Array.isArray(c) ? (c.find((x) => x && x.type === 'text') || {}).text : null;
|
|
57
|
+
} catch { // 잘린 줄: 첫 "text" 값만 꺼낸다
|
|
58
|
+
const m = /"content":"((?:[^"\\]|\\.)*)"|"type":"text","text":"((?:[^"\\]|\\.)*)"/.exec(line);
|
|
59
|
+
if (m) try { text = JSON.parse(`"${m[1] ?? m[2]}"`); } catch {}
|
|
60
|
+
}
|
|
61
|
+
if (text && text.trim() && !/^\s*</.test(text)) return text.trim();
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
} catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TITLE_TAIL_BYTES = 64 * 1024;
|
|
68
|
+
/** transcript 끝부분에서 마지막 세션 제목(custom-title 기록)을 읽는다. 없거나 못 읽으면 null */
|
|
69
|
+
function defaultTitle(file) {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
fd = fs.openSync(file, 'r');
|
|
73
|
+
const size = fs.fstatSync(fd).size;
|
|
74
|
+
const len = Math.min(size, TITLE_TAIL_BYTES);
|
|
75
|
+
const buf = Buffer.alloc(len);
|
|
76
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
77
|
+
const lines = buf.toString('utf8').split('\n');
|
|
78
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
79
|
+
if (!lines[i].includes('"custom-title"')) continue;
|
|
80
|
+
try { const j = JSON.parse(lines[i]); if (j.type === 'custom-title' && typeof j.customTitle === 'string' && j.customTitle.trim()) return j.customTitle.trim(); } catch {}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
} catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createState({ me = 'me', statFile = defaultStat, titleOf = defaultTitle, firstPromptOf = defaultFirstPrompt, styleFile = null } = {}) {
|
|
44
87
|
const emitter = new EventEmitter();
|
|
45
88
|
const floors = new Map(); // cwd → Floor
|
|
46
89
|
const sessions = new Map(); // session_id → Session
|
|
@@ -115,13 +158,14 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
|
|
|
115
158
|
s = {
|
|
116
159
|
id: ev.sessionId, floorId: floor.id, title: ev.sessionTitle, status: 'idle',
|
|
117
160
|
startedAt: now, lastActivity: now, agents: {}, pendingSubagents: [], ended: false, firstPrompt: '', lastPrompt: '',
|
|
118
|
-
transcriptPath: ev.transcriptPath, lastStatAt: 0,
|
|
161
|
+
transcriptPath: ev.transcriptPath, lastStatAt: 0, titleMtime: 0, firstPromptChecked: false, hookTitle: '', fileTitle: '',
|
|
119
162
|
cwd: ev.cwd || '', branch: proj ? proj.branch : '', worktree: proj && proj.worktree ? path.basename(proj.worktreeDir) : '',
|
|
120
163
|
};
|
|
121
164
|
sessions.set(s.id, s);
|
|
122
165
|
floor.sessions.add(s.id);
|
|
123
166
|
}
|
|
124
|
-
|
|
167
|
+
// 제목: 훅의 session_title 이 바뀔 때마다 따른다 (분리된 세션은 부모 제목으로 시작했다가 이름이 바뀐다)
|
|
168
|
+
if (ev.sessionTitle && ev.sessionTitle !== s.hookTitle) { s.hookTitle = ev.sessionTitle; retitle(s, floor, ev.sessionTitle, now); }
|
|
125
169
|
if (ev.transcriptPath) s.transcriptPath = ev.transcriptPath;
|
|
126
170
|
s.lastActivity = now;
|
|
127
171
|
floor.lastActivity = now;
|
|
@@ -183,6 +227,15 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
|
|
|
183
227
|
|
|
184
228
|
function schedule(at, fn) { timers.push({ at, fn }); }
|
|
185
229
|
|
|
230
|
+
/** 세션 제목 변경. 이전 제목이 있었으면 로그 한 줄 + update 를 보낸다. 바뀌었으면 true */
|
|
231
|
+
function retitle(s, floor, title, now) {
|
|
232
|
+
if (!title || title === s.title) return false;
|
|
233
|
+
const had = !!s.title;
|
|
234
|
+
s.title = title;
|
|
235
|
+
if (had && floor) emitUpdate(s, floor, pushLog(now, floor, s, s.agents.main || null, '🏷️', `제목: ${cut(title, 60)}`));
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
186
239
|
// ---------- 전이 ----------
|
|
187
240
|
function apply(payload, now = Date.now()) {
|
|
188
241
|
if (!payload || typeof payload !== 'object') return;
|
|
@@ -354,12 +407,27 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
|
|
|
354
407
|
if (s.ended) continue;
|
|
355
408
|
const idle = now - s.lastActivity;
|
|
356
409
|
if (idle >= SESSION_TTL_MS) { endSilently(s, now, '2시간 무활동'); continue; }
|
|
357
|
-
// transcript
|
|
358
|
-
if (
|
|
410
|
+
// transcript 확인은 세션당 1분에 한 번 (stat 한 번을 제목 · 퇴근 감지가 같이 쓴다)
|
|
411
|
+
if (s.transcriptPath && (!s.lastStatAt || now - s.lastStatAt >= STAT_INTERVAL_MS)) {
|
|
359
412
|
s.lastStatAt = now;
|
|
360
413
|
const st = statFile(s.transcriptPath);
|
|
361
|
-
|
|
362
|
-
if (st
|
|
414
|
+
// 첫 요청: 저널은 최근 2시간만 재생하므로 오래된 세션은 첫 요청 자리에 최근 프롬프트가 들어간다 → transcript 에서 한 번 복원
|
|
415
|
+
if (st && !s.firstPromptChecked) {
|
|
416
|
+
s.firstPromptChecked = true;
|
|
417
|
+
const fp = firstPromptOf(s.transcriptPath);
|
|
418
|
+
if (fp) s.firstPrompt = cut(fp, 120);
|
|
419
|
+
}
|
|
420
|
+
// 제목: 훅 없이 이름만 바뀐 경우 transcript 의 마지막 custom-title 로 따라간다 (파일이 바뀌었을 때만 읽음)
|
|
421
|
+
if (st && st.mtimeMs !== s.titleMtime) {
|
|
422
|
+
s.titleMtime = st.mtimeMs;
|
|
423
|
+
const t = titleOf(s.transcriptPath);
|
|
424
|
+
if (t && t !== s.fileTitle) { s.fileTitle = t; retitle(s, floors.get(s.floorId), t, now); }
|
|
425
|
+
}
|
|
426
|
+
// transcript 가 더 이상 갱신되지 않으면 터미널이 닫힌 것으로 본다
|
|
427
|
+
if (idle >= GONE_CHECK_MS) {
|
|
428
|
+
if (!st) { endSilently(s, now, 'transcript 없음'); continue; }
|
|
429
|
+
if (st.mtimeMs <= s.lastActivity && idle >= GONE_MS) { endSilently(s, now, '터미널 종료 추정'); continue; }
|
|
430
|
+
}
|
|
363
431
|
}
|
|
364
432
|
if (idle >= AWAY_MS && s.status !== 'away') {
|
|
365
433
|
s.status = 'away';
|
|
@@ -390,4 +458,4 @@ function createState({ me = 'me', statFile = defaultStat, styleFile = null } = {
|
|
|
390
458
|
return { apply, remove, removeDemo, sweep, snapshot, setFloorStyle, on: emitter.on.bind(emitter), get: (id) => sessions.get(id) };
|
|
391
459
|
}
|
|
392
460
|
|
|
393
|
-
module.exports = { createState, restRoomFor, REST_ROOMS, REST_BUBBLE, REST_ICON, LOG_LIMIT, SESSION_TTL_MS, SUBAGENT_LEAVE_MS, SESSION_LEAVE_MS, AWAY_MS, GONE_CHECK_MS, GONE_MS, STAT_INTERVAL_MS };
|
|
461
|
+
module.exports = { defaultTitle, defaultFirstPrompt, createState, restRoomFor, REST_ROOMS, REST_BUBBLE, REST_ICON, LOG_LIMIT, SESSION_TTL_MS, SUBAGENT_LEAVE_MS, SESSION_LEAVE_MS, AWAY_MS, GONE_CHECK_MS, GONE_MS, STAT_INTERVAL_MS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-ai-office",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.6",
|
|
4
4
|
"description": "Claude Code 세션 활동을 아이소메트릭 사무실로 실시간 시각화",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"uninstall-hooks": "node scripts/install-hooks.js --remove",
|
|
46
46
|
"test": "node --test test/*.test.js",
|
|
47
47
|
"cli": "node bin/ai-office.js",
|
|
48
|
-
"app:dev": "
|
|
49
|
-
"app:build": "
|
|
48
|
+
"app:dev": "node scripts/fetch-node.js && npm --prefix app run dev",
|
|
49
|
+
"app:build": "node scripts/fetch-node.js && npm --prefix app run build",
|
|
50
50
|
"app:icon": "npm --prefix app run icon",
|
|
51
51
|
"bump:patch": "node scripts/bump-version.js patch",
|
|
52
52
|
"bump:minor": "node scripts/bump-version.js minor",
|
package/public/index.html
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터 외형 고르기 — 프리셋·몸 종류(사람·로봇·고양이·곰)·파츠, 지금 보는 세션의 캐릭터 이름">👤 고르기…</button></div>
|
|
43
43
|
<div class="settings-row"><span class="settings-label">시간대 조명</span><select id="daylight-select" class="tool" title="실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요"><option value="auto">🕒 자동 (실제 시각)</option><option value="off">끔</option><option value="morning">🌅 아침</option><option value="day">☀️ 낮</option><option value="evening">🌇 저녁</option><option value="night">🌙 밤</option></select></div>
|
|
44
44
|
<div class="settings-row"><span class="settings-label">층 인테리어</span><button id="floor-style-btn" class="tool" title="지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다">🎨 고르기…</button></div>
|
|
45
|
+
<div class="settings-row"><span class="settings-label">언어</span><select id="lang-select" class="tool" title="화면 언어"><option value="ko">한국어</option><option value="en">English</option></select></div>
|
|
45
46
|
<div class="settings-row"><span class="settings-label">단축키 <kbd>?</kbd></span><button id="keys-btn" class="tool" title="키보드 단축키 표">⌨️ 보기…</button></div>
|
|
46
47
|
</div>
|
|
47
48
|
</div>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// 언어 (FE-45): 한국어가 원문이고 영어는 번역이다.
|
|
2
|
+
// - tr(text): 캔버스·코드에서 만든 문자열을 번역한다 (사전 → 패턴 규칙 → 구절 치환). 한국어 모드면 그대로.
|
|
3
|
+
// - DOM 은 MutationObserver 가 텍스트 노드·title·placeholder 를 자동으로 바꾼다 (원문은 WeakMap 에 보관해 되돌릴 수 있다).
|
|
4
|
+
// 서버가 보내는 말풍선·로그도 한국어라 같은 규칙으로 번역한다.
|
|
5
|
+
const KEY = 'aiOffice.lang';
|
|
6
|
+
export const LANGS = [['ko', '한국어'], ['en', 'English']];
|
|
7
|
+
/** 브라우저면 localStorage, Node(테스트)면 테스트가 직접 넣은 것만 쓴다 — Node 25+ 의 실험용 localStorage getter 는 접근만 해도 경고를 찍는다 */
|
|
8
|
+
function storage() {
|
|
9
|
+
try {
|
|
10
|
+
if (typeof window !== 'undefined') return window.localStorage;
|
|
11
|
+
const d = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
|
12
|
+
return d && 'value' in d ? d.value : null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
let lang = (() => {
|
|
16
|
+
try { const v = storage()?.getItem(KEY); if (v === 'ko' || v === 'en') return v; } catch {}
|
|
17
|
+
return (typeof navigator !== 'undefined' && /^ko/i.test(navigator.language || '')) ? 'ko' : 'en';
|
|
18
|
+
})();
|
|
19
|
+
export function getLang() { return lang; }
|
|
20
|
+
const listeners = new Set();
|
|
21
|
+
export function onLangChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
22
|
+
|
|
23
|
+
// ---------- 사전 (정확히 일치) ----------
|
|
24
|
+
const DICT = {
|
|
25
|
+
// 방·시설
|
|
26
|
+
'자료실': 'Library', '개발실': 'Dev Room', '정보검색실': 'Research Room', '회의실': 'Meeting Room', '휴게실': 'Lounge', '서버실': 'Server Room',
|
|
27
|
+
'내 자리': 'My Desk', '로비': 'Lobby', '엘리베이터': 'Elevator', '카페': 'Café', '테라스': 'Terrace', '복도': 'Corridor',
|
|
28
|
+
'세미나실': 'Seminar Room', '스튜디오': 'Studio', '헬스장': 'Gym', '옥상정원': 'Rooftop Garden', '북 라운지': 'Book Lounge', '게임룸': 'Game Room', '구내식당': 'Canteen', '수면실': 'Nap Room',
|
|
29
|
+
'카페 · 테라스': 'Café · Terrace', '세미나실 · 스튜디오': 'Seminar Room · Studio', '헬스장 · 옥상정원': 'Gym · Rooftop Garden', '북 라운지 · 게임룸': 'Book Lounge · Game Room', '구내식당 · 수면실': 'Canteen · Nap Room',
|
|
30
|
+
'📖 자료실': '📖 Library', '✏️ 개발실': '✏️ Dev Room', '🖥️ 서버실': '🖥️ Server Room', '🌐 정보검색실': '🌐 Research Room', '🤝 회의실': '🤝 Meeting Room', '☕ 휴게실': '☕ Lounge', '🙋 내 자리': '🙋 My Desk', '🛗 엘리베이터': '🛗 Elevator',
|
|
31
|
+
'파일 읽기 · 검색': 'reading · searching files', '코드 작성 · 수정': 'writing · editing code', '명령 실행 · git': 'commands · git', '웹 · 외부 도구': 'web · external tools', '계획 · 일 나누기': 'planning · delegating', '휴식 · 대기': 'resting · idle', '질문 · 허락 요청': 'questions · permission', '서브에이전트 출퇴근': 'subagents in and out',
|
|
32
|
+
// 상태·말풍선 (서버)
|
|
33
|
+
'고민 중…': 'thinking…', '고민 중': 'thinking', '생각 중': 'thinking', '작업 중': 'working', '답 기다리는 중': 'waiting for you', '답 기다림': 'waiting', '대기': 'idle', '조용함': 'quiet', '자리 비움': 'away', '퇴근': 'left', '휴식 중': 'resting',
|
|
34
|
+
'커피 마시는 중': 'having coffee', '바람 쐬는 중': 'getting some air', '밥 먹는 중': 'eating', '낮잠 자는 중': 'napping', '운동 중': 'working out', '정원 산책 중': 'strolling the garden', '책 읽는 중': 'reading a book', '게임 한 판': 'a quick game', '녹음 중': 'recording', '세미나 듣는 중': 'at a seminar',
|
|
35
|
+
'출근했습니다': 'arrived', '퇴근합니다': 'leaving', '요청 받음': 'request received', '입력 요청': 'input requested', '입력이 필요해요': 'input needed', '질문 있어요': 'I have a question', '허락이 필요해요': 'need permission',
|
|
36
|
+
'할 일 기다리는 중': 'waiting for work', '기억 정리 중': 'compacting memory', '실패했네, 다시 생각 중': 'failed, thinking again', '알림 확인 중': 'checking a notification', '작업 완료': 'done', '자리 비움 (30분 무활동)': 'away (30 min idle)', '터미널 종료 추정': 'terminal probably closed',
|
|
37
|
+
'할 일 정리 중': 'organizing tasks', '계획 세우는 중': 'planning', '서브에이전트': 'subagent', '부': 'sub', '제목 없는 세션': 'untitled session', '사무실': 'Office', '나': 'me',
|
|
38
|
+
'☕ 라떼 한 잔': '☕ a latte', '🪟 창밖 구경': '🪟 looking outside', '🌤️ 바람 좋다': '🌤️ nice breeze', '커피 한 잔': 'a cup of coffee', '소파에서 잠깐': 'a moment on the sofa', '화분 구경': 'admiring plants', '창밖 구경': 'looking outside', '로비 산책': 'walking the lobby', '벤치에서 한숨': 'a sigh on the bench', '카페에서 라떼': 'a latte at the café', '창가 자리': 'window seat', '카페에서 수다': 'chatting at the café', '테라스 산책': 'a walk on the terrace', '벤치에서 햇볕': 'sun on the bench', '라떼 한 잔': 'a latte', '바람 좋다': 'nice breeze',
|
|
39
|
+
// 헤더·패널
|
|
40
|
+
'층별 안내': 'Floors', 'FLOOR GUIDE': 'FLOOR GUIDE', '사무실 사람들': 'People', '활동 로그': 'Activity log', '어디서 무엇을 하나': 'Who goes where', '세션': 'Session', '프로젝트': 'Project', '캐릭터': 'Character', '이어가기': 'Resume', '복사': 'Copy', '복사됨': 'Copied', '복사 실패': 'Copy failed',
|
|
41
|
+
'훅 이벤트를 기다리는 중': 'waiting for hook events', '세션을 선택하세요': 'select a session', '아직 출근한 사람이 없습니다': 'Nobody is in yet', 'Claude Code 세션이 시작되면 여기에 나타납니다.': 'Characters appear here when a Claude Code session starts.', '훅을 아직 설치하지 않았다면': 'If the hooks are not installed yet', '미리 보려면': 'For a preview, run',
|
|
42
|
+
'아직 열린 층이 없습니다': 'No floors yet', '이 층에는 아무도 없습니다': 'Nobody on this floor', '아직 활동이 없습니다': 'No activity yet', '조건에 맞는 로그가 없습니다': 'No log entries match', '지금 보는 중': 'viewing', '최근 세션 자동으로 따라가기': 'Follow the latest session automatically',
|
|
43
|
+
'드래그로 이동 · 휠로 확대 · 캐릭터를 클릭하면 세션 선택': 'Drag to pan · wheel to zoom · click a character to select its session', '? 단축키': '? shortcuts', '단축키': 'Shortcuts', '검색 (내용·이름)': 'Search (text · name)', '선택 세션만': 'Selected only', '선택한 세션의 로그만': 'Only the selected session', '접기/펼치기': 'Collapse / expand',
|
|
44
|
+
'🏢 전체 보기': '🏢 Building', '🏢 사무실로': '🏢 Back to office', '📍 따라가기': '📍 Follow', '⚙️ 설정': '⚙️ Settings', '설정': 'Settings', '💬 전체': '💬 All', '💬 선택만': '💬 Selected', '🔔 꺼짐': '🔔 Off', '🔔 켜짐': '🔔 On', '🔔 차단됨': '🔔 Blocked', '🔈 꺼짐': '🔈 Off', '✨ 꺼짐': '✨ Off', '✨ 켜짐': '✨ On', '🎬 꺼짐': '🎬 Off', '🎬 켜짐': '🎬 On', '☀️ 라이트': '☀️ Light', '🌙 다크': '🌙 Dark', '🌙 라이트': '🌙 Light', '👤 고르기…': '👤 Choose…', '🎨 고르기…': '🎨 Choose…', '⌨️ 보기…': '⌨️ Show…', '🗑 치우기': '🗑 Dismiss', '🗑 저장 삭제': '🗑 Delete saved', '🎲 랜덤': '🎲 Random', '🎲 다른 별명': '🎲 Another nickname', '↺ 초기화': '↺ Reset', '저장': 'Save', '닫기': 'Close', '이름': 'Name', '끔': 'Off', '자동': 'Auto', '자동 ': 'Auto ',
|
|
45
|
+
'말풍선 범위': 'Bubbles', '브라우저 알림': 'Browser notifications', '대기 알림 소리': 'Waiting chime', '소리 범위': 'Sound scope', '답 기다림만': 'Waiting only', '실패·종료도': 'Also failures · end', '생동감': 'Liveliness', '테마': 'Theme', '데모': 'Demo', '캐릭터 외형': 'Character looks', '시간대 조명': 'Daylight', '층 인테리어': 'Floor interior', '언어': 'Language',
|
|
46
|
+
'🕒 자동 (실제 시각)': '🕒 Auto (real clock)', '🌅 아침': '🌅 Morning', '☀️ 낮': '☀️ Day', '🌇 저녁': '🌇 Evening', '🌙 밤': '🌙 Night',
|
|
47
|
+
'⌨️ 키보드 단축키': '⌨️ Keyboard shortcuts', '층 번호로 이동 (7 → 7F, 12F 는': 'Go to floor number (7 → 7F; for 12F press', '연타)': 'in a row)', '이전 · 다음 층': 'Previous · next floor', '선택한 세션 따라가기 (카메라)': 'Follow the selected session (camera)', '전체 보기 (건물 단면) ↔ 사무실': 'Building view ↔ office', '데모 켜기 · 끄기': 'Demo on · off', '말풍선 범위 (전체 ↔ 선택만)': 'Bubbles (all ↔ selected)', '대기 알림 소리 켜기 · 끄기': 'Waiting chime on · off', '테마 (라이트 ↔ 다크)': 'Theme (light ↔ dark)', '활동 로그 검색': 'Search the activity log', '닫기 · 검색 지우기 · 따라가기 해제': 'Close · clear search · stop following', '이 도움말': 'This help', '입력란에 포커스가 있을 때는 단축키가 동작하지 않습니다': 'Shortcuts are off while an input field has focus',
|
|
48
|
+
'저장은 이 브라우저에만 남습니다 (localStorage)': 'Saved in this browser only (localStorage)', '— 고른 뒤 파츠를 더 바꿀 수 있어요': '— pick one, then tweak the parts', '· 바로 저장': '· saves immediately', '프리셋 ': 'Preset ', '프리셋': 'Preset', '캐릭터 이름 ': 'Character name ', '몸': 'Body', '머리': 'Hair', '상의': 'Top', '하의': 'Bottom', '신발': 'Shoes', '안경': 'Glasses', '모자': 'Hat', '소품': 'Extras', '피부색': 'Skin', '털색': 'Fur', '금속색': 'Metal', 'Claude 기본': 'Default Claude', '서브에이전트 기본': 'Default subagent', '세션 색 자동': 'session color (auto)', '직접 고르기': 'Custom', ' · 기본': ' · default', '다른 별명 뽑기': 'Draw another nickname',
|
|
49
|
+
// 아바타 옵션
|
|
50
|
+
'사람': 'Human', '로봇': 'Robot', '고양이': 'Cat', '곰': 'Bear', '짧은': 'Short', '앞머리': 'Bangs', '단발': 'Bob', '긴 머리': 'Long', '포니테일': 'Ponytail', '양갈래': 'Twin tails', '똥머리': 'Bun', '곱슬': 'Curly', '삐죽': 'Spiky', '민머리': 'Bald', '모호크': 'Mohawk', '아프로': 'Afro',
|
|
51
|
+
'티셔츠': 'T-shirt', '후드': 'Hoodie', '셔츠': 'Shirt', '목폴라': 'Turtleneck', '조끼': 'Vest', '정장': 'Suit', '가운': 'Gown', '원피스': 'Dress', '바지': 'Pants', '반바지': 'Shorts', '치마': 'Skirt',
|
|
52
|
+
'없음': 'None', '둥근 안경': 'Round glasses', '네모 안경': 'Square glasses', '선글라스': 'Sunglasses', '고글': 'Goggles', '외알 안경': 'Monocle', '캡': 'Cap', '비니': 'Beanie', '베레모': 'Beret', '머리띠': 'Headband', '버킷햇': 'Bucket hat', '실크햇': 'Top hat', '마법사': 'Wizard', '왕관': 'Crown', '헤드폰': 'Headphones', '스카프': 'Scarf', '사원증': 'Badge', '귀걸이': 'Earring', '나비넥타이': 'Bow tie', '수염': 'Beard', '마스크': 'Mask', '망토': 'Cape',
|
|
53
|
+
'개발자': 'Developer', '연구원': 'Researcher', '캐주얼': 'Casual', '예술가': 'Artist', '임금님': 'King',
|
|
54
|
+
// 층 스타일
|
|
55
|
+
'따뜻한 우드': 'Warm Wood', '쿨 그레이': 'Cool Gray', '민트': 'Mint', '라벤더': 'Lavender', '샌드': 'Sand', '파스텔': 'Pastel', '(데모)': '(demo)',
|
|
56
|
+
// 툴팁
|
|
57
|
+
'지금': 'Now', '대상': 'Target', '요청': 'Prompt', '브랜치': 'Branch', '경로': 'Path', '종류': 'Type', '이어가기: ': 'Resume: ', ' — 클릭해 선택하면 우측 세션 카드에서 복사': ' — click to select, then copy from the session card',
|
|
58
|
+
// 배너·기타
|
|
59
|
+
'방금': 'just now', '시작': 'start', '초기 위치': 'Reset view', '확대': 'Zoom in', '축소': 'Zoom out',
|
|
60
|
+
// 긴 title 들
|
|
61
|
+
'Claude 가 질문하거나 허락을 기다리면 브라우저 알림을 보냅니다': 'Browser notification when Claude asks a question or waits for permission',
|
|
62
|
+
"가짜 세션 4개로 사무실을 북적이게 (실제 세션과 별도 층, '(데모)' 표시)": "Fill the office with 4 fake sessions (separate floors marked '(demo)')",
|
|
63
|
+
'끝난 세션을 목록에서 치우기 (TTL 을 기다리지 않음)': 'Remove the finished session from the list now (without waiting for the TTL)',
|
|
64
|
+
'나 / Claude / 서브에이전트 캐릭터 외형 고르기 — 프리셋·몸 종류(사람·로봇·고양이·곰)·파츠, 지금 보는 세션의 캐릭터 이름': 'Customize me / Claude / subagents — presets, body (human · robot · cat · bear), parts, and the name of the session you are viewing',
|
|
65
|
+
'다크 모드 전환': 'Toggle dark mode', '다크 모드로': 'Switch to dark', '라이트 모드로': 'Switch to light',
|
|
66
|
+
'답을 기다리는 세션이 생기면 짧은 소리 (한 번 클릭해 켜야 브라우저가 소리를 허용)': 'Short chime when a session starts waiting for you (click once so the browser allows audio)',
|
|
67
|
+
'답을 기다릴 때만 (높은 2음)': 'Only when waiting for you (high two-tone)', '도구 실패·세션 종료에도 낮은 2음': 'Also on tool failure and session end (low two-tone)',
|
|
68
|
+
'말풍선을 선택한 세션만 보일지 전체를 보일지': 'Show bubbles for the selected session only, or for all', '선택한 세션의 Claude 를 카메라가 따라갑니다 (F)': 'The camera follows the selected session\'s Claude (F)',
|
|
69
|
+
'쉬는 캐릭터가 휴게실·카페·테라스 안을 옮겨 다니거나 산책하는 자율 행동': 'Resting characters wander around their rest room and go for walks',
|
|
70
|
+
'실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요': 'Window, terrace and room light follow the real clock (morning 06–10 · day 10–17 · evening 17–20 · night 20–06). You can also fix one period for a preview',
|
|
71
|
+
'이 세션 캐릭터의 이름 — 비우면 자동 별명, 이 브라우저에만 저장': 'This session\'s character name — empty means an automatic nickname; saved in this browser only',
|
|
72
|
+
'저장한 설정을 지우고 기본값으로': 'Delete the saved look and go back to defaults',
|
|
73
|
+
'지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다': 'Interior preset of the floor you are viewing (same as 🎨 in Floors) — changes colors and the office layout',
|
|
74
|
+
'층을 건물처럼 쌓아 한눈에 (O)': 'Stack the floors like a building (O)', '키보드 단축키 표': 'Keyboard shortcut table', '터미널에서 이 세션을 이어가는 명령을 복사': 'Copy the command that resumes this session in a terminal', '이 층 인테리어 스타일 고르기': 'Pick this floor\'s interior style',
|
|
75
|
+
'이 브라우저는 알림을 지원하지 않습니다': 'This browser does not support notifications', '답을 기다리고 있어요': 'waiting for your answer',
|
|
76
|
+
'한국어 / English': '한국어 / English', '화면 언어': 'UI language',
|
|
77
|
+
'드래그로 이동': 'Drag to pan', '휠로 확대': 'wheel to zoom', '캐릭터를 클릭하면 세션 선택': 'click a character to select its session',
|
|
78
|
+
'숫자 층 번호로 이동 (12F 는 1,2 연타)': 'Digits go to a floor number (12F: press 1, 2)', '←/→ 이전·다음 층': '←/→ previous · next floor', 'F 따라가기': 'F follow', 'A 자동 따라가기': 'A auto-follow', 'D 데모': 'D demo', 'O 전체 보기': 'O building view', 'B 말풍선': 'B bubbles', 'S 소리': 'S sound', 'T 테마': 'T theme', '/ 로그 검색': '/ log search', 'Esc 닫기': 'Esc close', '? 이 도움말': '? this help',
|
|
79
|
+
'어디서 무엇을 하나 ': 'Who goes where ',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ---------- 패턴 규칙 (템플릿 문자열) ----------
|
|
83
|
+
const RULES = [
|
|
84
|
+
[/^"(.+)" 검색 중$/, (m) => `searching "${m[1]}"`],
|
|
85
|
+
[/^(.+) 읽는 중$/, (m) => `reading ${m[1]}`],
|
|
86
|
+
[/^(.+) 수정 중$/, (m) => `editing ${m[1]}`],
|
|
87
|
+
[/^(.+) 작성 중$/, (m) => `writing ${m[1]}`],
|
|
88
|
+
[/^(.+) 실행 중$/, (m) => `running ${m[1]}`],
|
|
89
|
+
[/^(.+) 찾는 중$/, (m) => `looking for ${m[1]}`],
|
|
90
|
+
[/^(.+) 둘러보는 중$/, (m) => `browsing ${m[1]}`],
|
|
91
|
+
[/^(.+) 맡기는 중$/, (m) => `delegating: ${m[1]}`],
|
|
92
|
+
[/^(.+) 조회 중$/, (m) => `querying ${m[1]}`],
|
|
93
|
+
[/^(.+) 사용 중$/, (m) => `using ${m[1]}`],
|
|
94
|
+
[/^(.+) 실패: (.+)$/, (m) => `${m[1]} failed: ${m[2]}`],
|
|
95
|
+
[/^허락 요청: (.+)$/, (m) => `permission request: ${m[1]}`],
|
|
96
|
+
[/^제목: (.+)$/, (m) => `title: ${m[1]}`],
|
|
97
|
+
[/^완료: (.+)$/, (m) => `done: ${m[1]}`],
|
|
98
|
+
[/^알림 수신 \((.+)\)$/, (m) => `notification (${m[1]})`],
|
|
99
|
+
[/^세션 종료 \((.+)\)$/, (m) => `session ended (${m[1]})`],
|
|
100
|
+
[/^세션 (startup|resume|clear|compact|\w+)$/, (m) => `session ${m[1]}`],
|
|
101
|
+
[/^기억 정리 \((.+)\)$/, (m) => `compacting memory (${m[1]})`],
|
|
102
|
+
[/^(.+) 출근$/, (m) => `${m[1]} arrived`],
|
|
103
|
+
[/^(.+) 퇴근(: .*)?$/, (m) => `${m[1]} left${m[2] || ''}`],
|
|
104
|
+
[/^(.+) 세션을 목록에서 치우기$/, (m) => `Dismiss this ${tr(m[1])} session`],
|
|
105
|
+
[/^(\d+)F (.+) 의 Claude$/, (m) => `Claude of ${m[1]}F ${m[2]}`],
|
|
106
|
+
[/^(.+) \(나\)$/, (m) => `${m[1]} (me)`],
|
|
107
|
+
[/^(.+) 프리셋 적용$/, (m) => `Apply the ${tr(m[1])} preset`],
|
|
108
|
+
[/^🎨 (\d+)F 인테리어$/, (m) => `🎨 ${m[1]}F interior`],
|
|
109
|
+
[/^(.+) 의 Claude$/, (m) => `Claude of ${m[1]}`],
|
|
110
|
+
[/^세션 (\d+)개$/, (m) => `${m[1]} session${m[1] === '1' ? '' : 's'}`],
|
|
111
|
+
[/^프로젝트 (\d+)개$/, (m) => `${m[1]} project${m[1] === '1' ? '' : 's'}`],
|
|
112
|
+
[/^(\d+)명 근무 중$/, (m) => `${m[1]} at work`],
|
|
113
|
+
[/^(\d+)명 대기$/, (m) => `${m[1]} waiting`],
|
|
114
|
+
[/^(\d+)건$/, (m) => `${m[1]} entries`],
|
|
115
|
+
[/^(\d+) \/ (\d+)건$/, (m) => `${m[1]} / ${m[2]} entries`],
|
|
116
|
+
[/^(\d+)초$/, (m) => `${m[1]}s`],
|
|
117
|
+
[/^(\d+)분 (\d+)초$/, (m) => `${m[1]}m ${m[2]}s`],
|
|
118
|
+
[/^(\d+)시간 (\d+)분$/, (m) => `${m[1]}h ${m[2]}m`],
|
|
119
|
+
[/^(\d+)초 전$/, (m) => `${m[1]}s ago`],
|
|
120
|
+
[/^(\d+)분 전$/, (m) => `${m[1]} min ago`],
|
|
121
|
+
[/^(\d+)시간 전$/, (m) => `${m[1]} h ago`],
|
|
122
|
+
[/^(\d+)일 전$/, (m) => `${m[1]} d ago`],
|
|
123
|
+
[/^세션 시작 (.+) · 마지막 활동 (.+) · (.+)$/, (m) => `started ${tr(m[1])} · last activity ${tr(m[2])} · ${m[3]}`],
|
|
124
|
+
[/^(.+) \(데모\)$/, (m) => `${m[1]} (demo)`],
|
|
125
|
+
[/^· (.+)$/, (m) => `· ${tr(m[1])}`],
|
|
126
|
+
[/^(\d+)F (.+)$/, (m) => `${m[1]}F ${tr(m[2])}`],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const HANGUL = /[가-힣]/;
|
|
131
|
+
const EMOJI_LEAD = /^(\p{Extended_Pictographic}\uFE0F?)\s+(.+)$/u;
|
|
132
|
+
// 사전 → 패턴 → 이모지 머리 → ' → ' / ' · ' 로 나눠 조각별. 모르는 조각(사용자 프롬프트·세션 제목·Claude 메시지)은 손대지 않는다
|
|
133
|
+
function translate(s) {
|
|
134
|
+
if (DICT[s] !== undefined) return DICT[s];
|
|
135
|
+
const t = s.trim();
|
|
136
|
+
if (t !== s && DICT[t] !== undefined) return s.replace(t, DICT[t]);
|
|
137
|
+
if (/^".*"$/s.test(t)) return s; // 로그의 "프롬프트" 는 사용자 글
|
|
138
|
+
for (const [re, fn] of RULES) { const m = re.exec(t); if (m) return s.replace(t, fn(m)); }
|
|
139
|
+
const em = EMOJI_LEAD.exec(t);
|
|
140
|
+
if (em) { const inner = tr(em[2]); return inner === em[2] ? s : s.replace(t, `${em[1]} ${inner}`); }
|
|
141
|
+
if (t.includes(' → ')) return s.replace(t, t.split(' → ').map((p) => tr(p)).join(' → '));
|
|
142
|
+
if (t.includes(' · ')) return s.replace(t, t.split(' · ').map((p) => tr(p)).join(' · '));
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
/** 문자열 번역. 한국어 모드이거나 한글이 없으면 그대로 */
|
|
146
|
+
export function tr(s) {
|
|
147
|
+
if (lang !== 'en' || typeof s !== 'string' || !HANGUL.test(s)) return s;
|
|
148
|
+
let v = cache.get(s);
|
|
149
|
+
if (v === undefined) { v = translate(s); if (cache.size > 5000) cache.clear(); cache.set(s, v); }
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------- DOM ----------
|
|
154
|
+
const originals = new WeakMap(); // Text → 원문 (앱이 쓴 값)
|
|
155
|
+
const written = new WeakMap(); // Text → 우리가 마지막으로 쓴 값
|
|
156
|
+
const attrOrig = new WeakMap(); // Element → { attr: 원문 }
|
|
157
|
+
const attrWritten = new WeakMap();// Element → { attr: 우리가 쓴 값 }
|
|
158
|
+
const ATTRS = ['title', 'placeholder', 'data-tip', 'aria-label'];
|
|
159
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'CODE', 'PRE', 'KBD']);
|
|
160
|
+
function applyText(node) {
|
|
161
|
+
const cur = node.nodeValue;
|
|
162
|
+
if (written.get(node) !== cur) originals.set(node, cur); // 처음 보거나 앱이 새로 썼다
|
|
163
|
+
const orig = originals.get(node);
|
|
164
|
+
const want = lang === 'en' ? tr(orig) : orig;
|
|
165
|
+
if (cur !== want) node.nodeValue = want;
|
|
166
|
+
written.set(node, want);
|
|
167
|
+
}
|
|
168
|
+
function applyAttrs(el) {
|
|
169
|
+
let o = attrOrig.get(el), w = attrWritten.get(el);
|
|
170
|
+
for (const a of ATTRS) {
|
|
171
|
+
if (!el.hasAttribute(a)) continue;
|
|
172
|
+
if (!o) { o = {}; attrOrig.set(el, o); w = {}; attrWritten.set(el, w); }
|
|
173
|
+
const cur = el.getAttribute(a);
|
|
174
|
+
if (w[a] !== cur) o[a] = cur;
|
|
175
|
+
const want = lang === 'en' ? tr(o[a]) : o[a];
|
|
176
|
+
if (cur !== want) el.setAttribute(a, want);
|
|
177
|
+
w[a] = want;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function walk(root) {
|
|
181
|
+
if (!root) return;
|
|
182
|
+
if (root.nodeType === 3) { if (!root.parentNode || !SKIP.has(root.parentNode.nodeName)) applyText(root); return; }
|
|
183
|
+
if (root.nodeType !== 1 && root.nodeType !== 11) return;
|
|
184
|
+
if (root.nodeType === 1) { if (SKIP.has(root.nodeName)) return; applyAttrs(root); }
|
|
185
|
+
for (const c of root.childNodes) walk(c);
|
|
186
|
+
}
|
|
187
|
+
let observer = null;
|
|
188
|
+
/** 페이지에 붙인다: 지금 DOM 을 번역하고, 이후 바뀌는 노드도 따라간다 */
|
|
189
|
+
export function attach(root = document.body) {
|
|
190
|
+
walk(root);
|
|
191
|
+
if (observer) return;
|
|
192
|
+
observer = new MutationObserver((muts) => {
|
|
193
|
+
for (const m of muts) {
|
|
194
|
+
if (m.type === 'characterData') applyText(m.target);
|
|
195
|
+
else if (m.type === 'attributes') applyAttrs(m.target);
|
|
196
|
+
else for (const n of m.addedNodes) walk(n);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
observer.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ATTRS });
|
|
200
|
+
}
|
|
201
|
+
export function setLang(v) {
|
|
202
|
+
if (v !== 'ko' && v !== 'en') return;
|
|
203
|
+
if (v === lang) return;
|
|
204
|
+
lang = v;
|
|
205
|
+
try { storage()?.setItem(KEY, v); } catch {}
|
|
206
|
+
cache.clear();
|
|
207
|
+
if (typeof document !== 'undefined') { document.documentElement.lang = v; walk(document.body); }
|
|
208
|
+
for (const fn of listeners) fn(v);
|
|
209
|
+
}
|
package/public/js/main.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createFloorStyleBook } from './scene/floorStyle.js';
|
|
|
14
14
|
import { createFloorStylePicker } from './floorStylePicker.js';
|
|
15
15
|
import { layoutOfPreset } from './scene/map.js';
|
|
16
16
|
import { buildLayout, roomLabelIn } from './scene/layouts.js';
|
|
17
|
+
import { attach as attachI18n, setLang, getLang, tr } from './i18n.js';
|
|
17
18
|
|
|
18
19
|
const canvas = document.getElementById('office');
|
|
19
20
|
const scene = createScene(canvas, store);
|
|
@@ -248,8 +249,8 @@ store.subscribe((kind, detail) => {
|
|
|
248
249
|
const main = session.agents && session.agents.main;
|
|
249
250
|
if (!canNotify() || Notification.permission !== 'granted') return;
|
|
250
251
|
try {
|
|
251
|
-
const n = new Notification(`${floor ? floor.no + 'F ' + floor.name : '사무실'} · ${session.title || '제목 없는 세션'}`, {
|
|
252
|
-
body: main ? `${main.icon || '🙋'} ${main.bubble}` : '답을 기다리고 있어요',
|
|
252
|
+
const n = new Notification(`${floor ? floor.no + 'F ' + tr(floor.name) : tr('사무실')} · ${session.title || tr('제목 없는 세션')}`, {
|
|
253
|
+
body: main ? `${main.icon || '🙋'} ${tr(main.bubble)}` : tr('답을 기다리고 있어요'),
|
|
253
254
|
tag: `ai-office-${session.id}`,
|
|
254
255
|
});
|
|
255
256
|
n.onclick = () => { window.focus(); store.selectSession(session.id); n.close(); };
|
|
@@ -359,6 +360,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
359
360
|
e.preventDefault();
|
|
360
361
|
});
|
|
361
362
|
document.getElementById('stage-hint').textContent += ' · ? 단축키';
|
|
363
|
+
|
|
364
|
+
// ---------- 언어 (FE-45): DOM 은 i18n 이 자동 번역, 캔버스 글자는 tr() 로 그린다 ----------
|
|
365
|
+
const langSel = document.getElementById('lang-select');
|
|
366
|
+
langSel.value = getLang();
|
|
367
|
+
document.documentElement.lang = getLang();
|
|
368
|
+
langSel.onchange = () => { setLang(langSel.value); dirty = true; };
|
|
369
|
+
attachI18n(document.body);
|
|
362
370
|
document.getElementById('stage-hint').title = KEYS_HELP;
|
|
363
371
|
|
|
364
372
|
// ---------- 세션 이어가기 명령 복사 ----------
|
package/public/js/overview.js
CHANGED
|
@@ -3,6 +3,7 @@ import { GRID, project, worldBounds, layoutOfPreset } from './scene/map.js';
|
|
|
3
3
|
import { buildLayout } from './scene/layouts.js';
|
|
4
4
|
import { drawFloor, drawEntities, roundRect, withFloorStyle } from './scene/render.js';
|
|
5
5
|
import { palette, DEFAULT_PRESET } from './scene/floorStyle.js';
|
|
6
|
+
import { tr } from './i18n.js';
|
|
6
7
|
|
|
7
8
|
const MINI_SCALE = 0.5; // 미니 사무실 캐시 해상도 (월드 1px → 0.5px)
|
|
8
9
|
const STRIDE = 0.8; // 층 간격 (이미지 높이 대비) — 살짝 겹쳐 건물처럼
|
|
@@ -122,11 +123,12 @@ export function createOverview(canvas, store, { onPick, styleOf } = {}) {
|
|
|
122
123
|
const sessions = store.sessionsOfFloor(f.id);
|
|
123
124
|
const status = store.floorStatus(f.id);
|
|
124
125
|
const waiting = store.waitingOfFloor(f.id).length;
|
|
125
|
-
const
|
|
126
|
+
const fname = tr(f.name);
|
|
127
|
+
const name = fname.length > 22 ? fname.slice(0, 21) + '…' : fname;
|
|
126
128
|
ctx.font = '700 12px D2Coding, NanumSquare, system-ui, sans-serif';
|
|
127
129
|
const nameW = ctx.measureText(name).width;
|
|
128
130
|
ctx.font = '11px D2Coding, NanumSquare, system-ui, sans-serif';
|
|
129
|
-
const meta = `세션 ${sessions.length}개 · ${STATUS_LABEL[status]}${waiting ? ` · ${waiting}명 대기` : ''}
|
|
131
|
+
const meta = tr(`세션 ${sessions.length}개 · ${STATUS_LABEL[status]}${waiting ? ` · ${waiting}명 대기` : ''}`);
|
|
130
132
|
const metaW = ctx.measureText(meta).width;
|
|
131
133
|
const bw = Math.max(nameW, metaW) + 62, bh = 40;
|
|
132
134
|
const lx = ox + 8, ly = oy + 6;
|
|
@@ -6,6 +6,7 @@ import { findPath, nearestWalkable } from './pathfind.js';
|
|
|
6
6
|
import { shadow, shade, FONT } from './sprites.js';
|
|
7
7
|
import { roundRect, makeEntity } from './render.js';
|
|
8
8
|
import { elapsed } from '../store.js';
|
|
9
|
+
import { tr } from '../i18n.js';
|
|
9
10
|
import { drawAvatar } from './avatar.js';
|
|
10
11
|
|
|
11
12
|
const SPEED = 3.6; // 타일/초
|
|
@@ -151,7 +152,7 @@ function drawOverlay(ctx, camera, a, now, bubbleMode) {
|
|
|
151
152
|
ctx.textBaseline = 'middle';
|
|
152
153
|
// 이름표 (발 아래)
|
|
153
154
|
ctx.font = `700 9.5px ${FONT}`;
|
|
154
|
-
const name = a.user ? `${a.name} (나)` : a.name;
|
|
155
|
+
const name = a.user ? tr(`${a.name} (나)`) : a.name;
|
|
155
156
|
const nw = ctx.measureText(name).width + 18;
|
|
156
157
|
const ny = s.y + 10;
|
|
157
158
|
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
|
@@ -167,9 +168,9 @@ function drawOverlay(ctx, camera, a, now, bubbleMode) {
|
|
|
167
168
|
const amb = (a.ambient && a.ambient.phase !== 'back' ? a.ambient.spot : null) || roam;
|
|
168
169
|
if ((amb || a.bubble) && !a.user && (bubbleMode !== 'selected' || a.selected)) {
|
|
169
170
|
ctx.font = `8px ${FONT}`;
|
|
170
|
-
let text = amb ? `${amb.icon} ${amb.bubble}` : `${a.icon ? a.icon + ' ' : ''}${a.bubble}`;
|
|
171
|
+
let text = amb ? `${amb.icon} ${tr(amb.bubble)}` : `${a.icon ? a.icon + ' ' : ''}${tr(a.bubble)}`;
|
|
171
172
|
if (text.length > 30) text = text.slice(0, 29) + '…';
|
|
172
|
-
const ago = elapsed(a.since, Date.now()); // now 는 rAF 시각(페이지 로드 기준)이라 epoch 인 since 와 빼면 안 된다
|
|
173
|
+
const ago = tr(elapsed(a.since, Date.now())); // now 는 rAF 시각(페이지 로드 기준)이라 epoch 인 since 와 빼면 안 된다
|
|
173
174
|
const full = `${text} · ${ago}`;
|
|
174
175
|
const bw = Math.min(240, ctx.measureText(full).width + 16), bh = 17, r = 8.5;
|
|
175
176
|
const headTop = s.y - 62 * z;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 이 관계로 위상 정렬한다. 정적 엔티티끼리의 관계는 한 번만 계산해 두고, 캐릭터와의 관계만 매 프레임 계산한다.
|
|
6
6
|
import { GRID, ROOMS, CORRIDOR, FURNITURE, WALLS, DOORS, LABEL, project, roomAt, worldBounds, setTheme as setMapTheme, setFloorStyle as setMapFloorStyle } from './map.js';
|
|
7
7
|
import * as MAP from './map.js';
|
|
8
|
+
import { tr } from '../i18n.js';
|
|
8
9
|
import { tile, drawFurniture, wallPieces, drawWallPiece, drawDoorMat, drawDoorPost } from './sprites.js';
|
|
9
10
|
import { resolve as resolveDaylight, setPeriod as setDaylightPeriod, palette as daylight } from './daylight.js';
|
|
10
11
|
|
|
@@ -383,7 +384,7 @@ export function drawLabels(ctx, camera) {
|
|
|
383
384
|
for (const r of ROOMS) {
|
|
384
385
|
const p = project(r.x0 + 0.5, r.y0 + 0.5, 0);
|
|
385
386
|
const s = camera.worldToScreen(p.x, p.y);
|
|
386
|
-
const text = r.label;
|
|
387
|
+
const text = tr(r.label);
|
|
387
388
|
const w = ctx.measureText(text).width + 16;
|
|
388
389
|
const x = s.x, y = s.y - 6;
|
|
389
390
|
const dark = MAP.THEME === 'dark';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Tauri 사이드카용 Node 바이너리를 내려받는다 (산출물은 커밋하지 않음). 맥·리눅스·Windows 공통.
|
|
4
|
+
// node scripts/fetch-node.js # 현재 머신
|
|
5
|
+
// NODE_VERSION=22.12.0 node scripts/fetch-node.js
|
|
6
|
+
// node scripts/fetch-node.js --target x86_64-pc-windows-msvc # 교차 (CI)
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const { execFileSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const VER = process.env.NODE_VERSION || '22.12.0';
|
|
13
|
+
const TARGETS = { // rust triple → nodejs.org 배포 이름
|
|
14
|
+
'aarch64-apple-darwin': { plat: 'darwin-arm64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
|
|
15
|
+
'x86_64-apple-darwin': { plat: 'darwin-x64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
|
|
16
|
+
'x86_64-unknown-linux-gnu': { plat: 'linux-x64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
|
|
17
|
+
'aarch64-unknown-linux-gnu': { plat: 'linux-arm64', ext: 'tar.gz', bin: 'bin/node', out: 'node' },
|
|
18
|
+
'x86_64-pc-windows-msvc': { plat: 'win-x64', ext: 'zip', bin: 'node.exe', out: 'node.exe' },
|
|
19
|
+
'aarch64-pc-windows-msvc': { plat: 'win-arm64', ext: 'zip', bin: 'node.exe', out: 'node.exe' },
|
|
20
|
+
};
|
|
21
|
+
function hostTriple() {
|
|
22
|
+
const arch = { arm64: 'aarch64', x64: 'x86_64' }[process.arch];
|
|
23
|
+
const osn = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
|
|
24
|
+
return `${arch}-${osn}`;
|
|
25
|
+
}
|
|
26
|
+
const i = process.argv.indexOf('--target');
|
|
27
|
+
const triple = i >= 0 ? process.argv[i + 1] : hostTriple();
|
|
28
|
+
const t = TARGETS[triple];
|
|
29
|
+
if (!t) { console.error(`지원하지 않는 대상: ${triple}`); process.exit(1); }
|
|
30
|
+
|
|
31
|
+
const OUT = path.join(__dirname, '..', 'app', 'src-tauri', 'binaries');
|
|
32
|
+
const dest = path.join(OUT, triple.endsWith('windows-msvc') ? `node-${triple}.exe` : `node-${triple}`);
|
|
33
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
34
|
+
|
|
35
|
+
(async () => {
|
|
36
|
+
if (!fs.existsSync(dest)) {
|
|
37
|
+
const name = `node-v${VER}-${t.plat}`;
|
|
38
|
+
const url = `https://nodejs.org/dist/v${VER}/${name}.${t.ext}`;
|
|
39
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-office-node-'));
|
|
40
|
+
const archive = path.join(tmp, `${name}.${t.ext}`);
|
|
41
|
+
console.log(`downloading node v${VER} (${t.plat})…`);
|
|
42
|
+
const res = await fetch(url);
|
|
43
|
+
if (!res.ok) { console.error(`다운로드 실패 ${res.status} ${url}`); process.exit(1); }
|
|
44
|
+
fs.writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
|
|
45
|
+
// tar 는 맥·리눅스·Windows 10+ 모두 있고 zip 도 풀 수 있다
|
|
46
|
+
execFileSync('tar', ['-xf', archive, '-C', tmp], { stdio: 'inherit' });
|
|
47
|
+
fs.copyFileSync(path.join(tmp, name, t.bin), dest);
|
|
48
|
+
if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
|
|
49
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
const st = fs.statSync(dest);
|
|
52
|
+
console.log(`${dest} ${st.size} bytes`);
|
|
53
|
+
if (triple === hostTriple()) { try { console.log(execFileSync(dest, ['--version']).toString().trim()); } catch {} }
|
|
54
|
+
})().catch((e) => { console.error(e.message); process.exit(1); });
|
package/scripts/fetch-node.sh
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
#
|
|
3
|
-
|
|
4
|
-
# NODE_VERSION=22.12.0 sh scripts/fetch-node.sh
|
|
5
|
-
set -e
|
|
6
|
-
VER="${NODE_VERSION:-22.12.0}"
|
|
7
|
-
case "$(uname -s)-$(uname -m)" in
|
|
8
|
-
Darwin-arm64) PLAT=darwin-arm64; TRIPLE=aarch64-apple-darwin ;;
|
|
9
|
-
Darwin-x86_64) PLAT=darwin-x64; TRIPLE=x86_64-apple-darwin ;;
|
|
10
|
-
Linux-x86_64) PLAT=linux-x64; TRIPLE=x86_64-unknown-linux-gnu ;;
|
|
11
|
-
Linux-aarch64) PLAT=linux-arm64; TRIPLE=aarch64-unknown-linux-gnu ;;
|
|
12
|
-
*) echo "지원하지 않는 플랫폼: $(uname -s)-$(uname -m)"; exit 1 ;;
|
|
13
|
-
esac
|
|
14
|
-
OUT="$(cd "$(dirname "$0")/.." && pwd)/app/src-tauri/binaries"
|
|
15
|
-
mkdir -p "$OUT"
|
|
16
|
-
TMP="${TMPDIR:-/tmp}/node-$VER-$PLAT"
|
|
17
|
-
if [ ! -x "$OUT/node-$TRIPLE" ]; then
|
|
18
|
-
echo "downloading node v$VER ($PLAT)…"
|
|
19
|
-
curl -sSL -o "$TMP.tar.gz" "https://nodejs.org/dist/v$VER/node-v$VER-$PLAT.tar.gz"
|
|
20
|
-
mkdir -p "$TMP" && tar -xzf "$TMP.tar.gz" -C "$TMP" --strip-components=1
|
|
21
|
-
cp "$TMP/bin/node" "$OUT/node-$TRIPLE" && chmod +x "$OUT/node-$TRIPLE"
|
|
22
|
-
rm -rf "$TMP" "$TMP.tar.gz"
|
|
23
|
-
fi
|
|
24
|
-
"$OUT/node-$TRIPLE" --version && ls -la "$OUT/node-$TRIPLE" | awk '{print $5" bytes"}'
|
|
2
|
+
# 호환용: Node 스크립트로 위임 (맥·리눅스·Windows 공통 구현은 fetch-node.js)
|
|
3
|
+
exec node "$(dirname "$0")/fetch-node.js" "$@"
|
package/scripts/install-hooks.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
|
+
const { stableNodePath } = require('../lib/nodepath');
|
|
9
10
|
|
|
10
11
|
const EVENTS = [
|
|
11
12
|
'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
@@ -21,7 +22,8 @@ const argNode = (() => { const i = process.argv.indexOf('--node'); return i >= 0
|
|
|
21
22
|
|
|
22
23
|
// Windows/macOS/Linux 공통: "<node 절대경로>" "<app>/hooks/emit.js" (공백·역슬래시 경로 대비 따옴표)
|
|
23
24
|
const q = (s) => `"${s}"`;
|
|
24
|
-
|
|
25
|
+
// --node 가 없으면 업그레이드에 안 깨지는 경로 (앱이 복사한 ~/.ai-office/node > Homebrew opt 링크 > 실행 중인 Node)
|
|
26
|
+
const COMMAND = buildCommand(argNode || stableNodePath(), path.join(APP_DIR, 'hooks', 'emit.js'), argPort);
|
|
25
27
|
function buildCommand(nodePath, emitPath, port) { return `${q(nodePath)} ${q(emitPath)}${port && port !== 4777 ? ` --port ${port}` : ''}`; }
|
|
26
28
|
|
|
27
29
|
function isOurs(h) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// 메뉴바(트레이)용 단색 템플릿 아이콘 생성: 검정 픽셀 + 알파만 있으면 macOS 가 테마에 맞춰 칠한다.
|
|
4
|
+
// 22pt 기준 설계, @1x(22px)·@2x(44px) 두 장을 app/src-tauri/icons/ 에 쓴다. 의존성 없음(PNG 직접 인코딩).
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const zlib = require('zlib');
|
|
8
|
+
|
|
9
|
+
// 22×22 캔버스. 건물 본체 + 창문 2×3 + 문 + 옥상 안테나. ('#' 불투명, '.' 투명)
|
|
10
|
+
const ART = [
|
|
11
|
+
'......................',
|
|
12
|
+
'..........#...........',
|
|
13
|
+
'..........#...........',
|
|
14
|
+
'....##############....',
|
|
15
|
+
'....##############....',
|
|
16
|
+
'....##..##..##..##....',
|
|
17
|
+
'....##..##..##..##....',
|
|
18
|
+
'....##############....',
|
|
19
|
+
'....##############....',
|
|
20
|
+
'....##..##..##..##....',
|
|
21
|
+
'....##..##..##..##....',
|
|
22
|
+
'....##############....',
|
|
23
|
+
'....##############....',
|
|
24
|
+
'....##..##..##..##....',
|
|
25
|
+
'....##..##..##..##....',
|
|
26
|
+
'....##############....',
|
|
27
|
+
'....##############....',
|
|
28
|
+
'....######....######..'.slice(0, 22),
|
|
29
|
+
'....######....######..'.slice(0, 22),
|
|
30
|
+
'....######....######..'.slice(0, 22),
|
|
31
|
+
'..####################',
|
|
32
|
+
'......................',
|
|
33
|
+
];
|
|
34
|
+
// 문 폭을 가운데 4px 로 맞추고 바닥선을 본체에 맞춘다
|
|
35
|
+
for (const y of [17, 18, 19]) ART[y] = '....#####....#####....';
|
|
36
|
+
ART[20] = '...################...';
|
|
37
|
+
|
|
38
|
+
function crc32(buf) { let c, crc = 0xffffffff; for (let n = 0; n < buf.length; n++) { c = (crc ^ buf[n]) & 0xff; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crc = (crc >>> 8) ^ c; } return (crc ^ 0xffffffff) >>> 0; }
|
|
39
|
+
function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, crc]); }
|
|
40
|
+
function png(size, scale) {
|
|
41
|
+
const raw = Buffer.alloc((size * 4 + 1) * size);
|
|
42
|
+
for (let y = 0; y < size; y++) {
|
|
43
|
+
raw[y * (size * 4 + 1)] = 0; // filter none
|
|
44
|
+
for (let x = 0; x < size; x++) {
|
|
45
|
+
const on = ART[Math.floor(y / scale)][Math.floor(x / scale)] === '#';
|
|
46
|
+
const o = y * (size * 4 + 1) + 1 + x * 4;
|
|
47
|
+
raw[o] = 0; raw[o + 1] = 0; raw[o + 2] = 0; raw[o + 3] = on ? 255 : 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
51
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
52
|
+
}
|
|
53
|
+
const out = path.join(__dirname, '..', 'app', 'src-tauri', 'icons');
|
|
54
|
+
fs.writeFileSync(path.join(out, 'tray.png'), png(22, 1));
|
|
55
|
+
fs.writeFileSync(path.join(out, 'tray@2x.png'), png(44, 2));
|
|
56
|
+
console.log('wrote', path.join(out, 'tray.png'), 'and tray@2x.png');
|
|
57
|
+
console.log(ART.join('\n'));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// 릴리스 뒤 Homebrew tap(cozyuj/homebrew-tap)의 cask·formula 버전과 sha256 을 갱신한다.
|
|
4
|
+
// node scripts/update-homebrew.js [--tap ../homebrew-tap] [--version 0.25.0]
|
|
5
|
+
// cask: GitHub Release 의 AI.Office_<v>_aarch64.dmg, formula: npm 의 claude-ai-office-<v>.tgz (npm 에 그 버전이 있을 때만)
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : def; };
|
|
11
|
+
const ROOT = path.join(__dirname, '..');
|
|
12
|
+
const TAP = path.resolve(arg('tap', path.join(ROOT, '..', 'homebrew-tap')));
|
|
13
|
+
const VERSION = arg('version', require(path.join(ROOT, 'package.json')).version);
|
|
14
|
+
|
|
15
|
+
async function sha256Of(url) {
|
|
16
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
17
|
+
if (!res.ok) throw new Error(`${res.status} ${url}`);
|
|
18
|
+
return crypto.createHash('sha256').update(Buffer.from(await res.arrayBuffer())).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
function rewrite(file, version, sha, urlVersionRe) {
|
|
21
|
+
let s = fs.readFileSync(file, 'utf8');
|
|
22
|
+
s = s.replace(/version "[^"]*"/, `version "${version}"`).replace(/sha256 "[0-9a-f]{64}"/, `sha256 "${sha}"`);
|
|
23
|
+
if (urlVersionRe) s = s.replace(urlVersionRe, (m) => m.replace(/\d+\.\d+\.\d+/, version));
|
|
24
|
+
fs.writeFileSync(file, s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
(async () => {
|
|
28
|
+
const dmg = `https://github.com/cozyuj/AI-office/releases/download/v${VERSION}/AI.Office_${VERSION}_aarch64.dmg`;
|
|
29
|
+
const caskSha = await sha256Of(dmg);
|
|
30
|
+
rewrite(path.join(TAP, 'Casks', 'ai-office.rb'), VERSION, caskSha);
|
|
31
|
+
console.log(`cask ${VERSION} ${caskSha}`);
|
|
32
|
+
|
|
33
|
+
const meta = await (await fetch('https://registry.npmjs.org/claude-ai-office')).json();
|
|
34
|
+
const npmV = meta.versions && meta.versions[VERSION] ? VERSION : meta['dist-tags'].latest;
|
|
35
|
+
const tgz = meta.versions[npmV].dist.tarball;
|
|
36
|
+
const formulaSha = await sha256Of(tgz);
|
|
37
|
+
rewrite(path.join(TAP, 'Formula', 'claude-ai-office.rb'), npmV, formulaSha, /claude-ai-office-\d+\.\d+\.\d+\.tgz/);
|
|
38
|
+
console.log(`formula ${npmV} ${formulaSha}${npmV !== VERSION ? ` (npm 에 ${VERSION} 이 아직 없어 ${npmV} 사용)` : ''}`);
|
|
39
|
+
})().catch((e) => { console.error(e.message); process.exit(1); });
|