diff --git a/README.md b/README.md index fac9441a5..d798e2cc9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ # 🏢 Agent Office +Local Tú Hub: the main controls, onboarding, sign-in, worker tasks and camera settings +are displayed in Vietnamese. Terminal output, entered prompts, provider/model IDs and +project documents are preserved. Add `?lang=en` to a page for the original interface. + **A 3D office your team shares with its coding agents.** Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, diff --git a/docs/controls.md b/docs/controls.md index 4a863358d..6e911cc3d 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -1,5 +1,10 @@ # Controls +The local Tú Hub uses Vietnamese UI labels by default: **Nhóm AI** (Workers), +**Cài đặt → Cá nhân → Góc nhìn camera** (Settings → You → Camera view), +and **Tạo AI và bắt đầu** (Hire & start). The native first/third-person camera is unchanged. +`?lang=en` keeps the upstream labels. Text inside terminals, documents and entered prompts is not translated. + Back to the [README](../README.md). | Key | Action | diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..994e4c279 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -4,6 +4,7 @@ // and the task queue. You're in the office as someone on the 2D view (PeerInfo.lite), not standing // anywhere in it. +import './shared/locale-vi'; import { Net } from './net'; import { AVATAR_COLORS, loadProfile, loadSettings, saveProfile, store } from './state'; import { randomLook } from '../shared/avatar'; diff --git a/src/client/login.ts b/src/client/login.ts index 7fe3aaae3..8611a7ccf 100644 --- a/src/client/login.ts +++ b/src/client/login.ts @@ -1,4 +1,5 @@ export {}; // a module, so its names don't clash with the other pages' scripts +import './shared/locale-vi'; const form = document.getElementById('form') as HTMLFormElement; const nameRow = document.getElementById('name-row') as HTMLLabelElement; diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..38b1d7f35 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,4 +1,5 @@ import './style.css'; +import './shared/locale-vi'; import { Net } from './net'; import { DesktopNotifier } from './notify'; import { store, loadProfile, loadSettings } from './state'; diff --git a/src/client/shared/locale-vi.ts b/src/client/shared/locale-vi.ts new file mode 100644 index 000000000..7565b93e5 --- /dev/null +++ b/src/client/shared/locale-vi.ts @@ -0,0 +1,68 @@ +import { vietnameseLabel } from './vi-labels'; + +// UI-only adapter: never translate terminal buffers, documents, code, chat or entered values. +const RAW = '.xterm,.term-host,pre,code,textarea,input,[contenteditable],script,style,[data-no-translate],.worker-name,.worker-task,.task-summary,.chat-log,.chat-text,.markdown,.doc-body'; +const attrs = ['title', 'aria-label', 'placeholder', 'data-drop']; + +function ignored(node: Node): boolean { + const el = node instanceof Element ? node : node.parentElement; + return !!el?.closest(RAW); +} + +function translateText(node: Node) { + if (node.nodeType !== Node.TEXT_NODE || ignored(node) || !node.nodeValue?.trim()) return; + const next = vietnameseLabel(node.nodeValue); + if (next !== node.nodeValue) node.nodeValue = next; +} + +function translateElement(el: Element) { + // Input placeholders are UI; their current values and child text are not. + if (el.closest('.xterm,.term-host,pre,code,textarea,[contenteditable],[data-no-translate]')) { + if (!(el instanceof HTMLTextAreaElement)) return; + } + for (const attr of attrs) { + const value = el.getAttribute(attr); + if (value) { + const next = vietnameseLabel(value); + if (next !== value) el.setAttribute(attr, next); + } + } +} + +function translateTree(node: Node) { + if (node instanceof Element) translateElement(node); + if (ignored(node)) return; + if (node.nodeType === Node.TEXT_NODE) return translateText(node); + const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const child = walker.currentNode; + if (child instanceof Element) translateElement(child); + else translateText(child); + } +} + +function install() { + if (new URLSearchParams(location.search).get('lang') === 'en') return; + document.documentElement.lang = 'vi'; + translateTree(document.body); + const pending = new Set(); + let scheduled = false; + new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'childList') record.addedNodes.forEach(node => pending.add(node)); + else pending.add(record.target); + } + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + for (const node of pending) if (node.isConnected) translateTree(node); + pending.clear(); + document.title = document.title.replace(/Agent Office/g, 'Văn phòng AI').replace('Sign in', 'Đăng nhập'); + }); + }).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: attrs }); + document.title = document.title.replace(/Agent Office/g, 'Văn phòng AI').replace('Sign in', 'Đăng nhập'); +} + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install, { once: true }); +else install(); diff --git a/src/client/shared/vi-labels.ts b/src/client/shared/vi-labels.ts new file mode 100644 index 000000000..61f39843a --- /dev/null +++ b/src/client/shared/vi-labels.ts @@ -0,0 +1,139 @@ +/** Vietnamese interface labels. Provider/model identifiers and terminal output are never translated. */ +export const VI: Record = { + 'Agent Office': 'Văn phòng AI', 'Agent Office · Sign in': 'Văn phòng AI · Đăng nhập', + 'Workers': 'Nhóm AI', 'Worker': 'AI thực hiện', 'People': 'Mọi người', 'You': 'Cá nhân', + 'Open': 'Mở', 'Together': 'Cộng tác', 'Office': 'Văn phòng', 'Settings': 'Cài đặt', + 'Issues': 'Đầu việc GitHub', 'Pull requests': 'Yêu cầu gộp mã', 'PRs': 'Yêu cầu gộp mã', + 'Task queue': 'Hàng đợi công việc', 'Queue': 'Hàng đợi', 'Services': 'Dịch vụ đang chạy', + 'Whiteboard': 'Bảng trắng', 'Meeting room': 'Phòng họp', 'In a meeting': 'Đang họp', + 'Search': 'Tìm kiếm', 'Docs': 'Tài liệu', 'Elevator': 'Chuyển dự án', 'Floors': 'Các dự án', + 'Floor': 'Dự án', 'Rooftop bar': 'Sân thượng', 'Leave voice': 'Rời trò chuyện thoại', + 'Join voice': 'Tham gia thoại', 'Unmute': 'Bật âm thanh', 'Mute': 'Tắt âm thanh', + 'Muted': 'Đã tắt âm', 'Stop sharing': 'Dừng chia sẻ', 'Share screen': 'Chia sẻ màn hình', + 'Sharing': 'Đang chia sẻ', 'Hang a picture': 'Treo ảnh', 'Stop hanging the picture': 'Dừng treo ảnh', + 'Invite teammates': 'Mời cộng tác viên', 'Accounts': 'Tài khoản', 'Your sign-ins': 'Đăng nhập dịch vụ', + 'Controls': 'Cách điều khiển', '2D view': 'Giao diện 2D', 'Upgrade the office': 'Nâng cấp văn phòng', + 'Update the office': 'Cập nhật văn phòng', 'Update': 'Cập nhật', 'Upgrading…': 'Đang nâng cấp…', + 'Next worker that needs you': 'AI tiếp theo đang chờ anh', 'New task': 'Giao việc mới', + 'Close': 'Đóng', 'Cancel': 'Hủy', 'Save': 'Lưu', 'Send': 'Gửi', 'Send ✨': 'Gửi ✨', + 'Edit': 'Chỉnh sửa', 'Delete': 'Xóa', 'Remove': 'Gỡ', 'Add': 'Thêm', 'Confirm': 'Xác nhận', + 'Continue': 'Tiếp tục', 'Never mind': 'Bỏ qua', 'Back': 'Quay lại', 'Done': 'Hoàn tất', + 'Hide': 'Ẩn cửa sổ', 'Hide terminal': 'Ẩn cửa sổ lệnh', 'Open terminal': 'Mở cửa sổ lệnh', + 'Terminal': 'Cửa sổ lệnh', 'Changes': 'Thay đổi mã', 'Models': 'Chọn model', 'Keys': 'Phím', + 'Prompt': 'Yêu cầu', 'Dictate': 'Nhập bằng giọng nói', 'Send home': 'Dừng AI', + 'Hire & start': 'Tạo AI và bắt đầu', 'Hire': 'Tạo AI', 'Name': 'Tên', + 'You can start with an empty prompt and send work later.': 'Có thể để trống yêu cầu và giao việc sau.', + 'Optional first task…': 'Nhập việc đầu tiên muốn giao (có thể để trống)…', + 'What should the worker work on?': 'Anh muốn AI thực hiện việc gì?', + 'Reply, or tell it what to do next…': 'Trả lời hoặc giao việc tiếp theo…', + 'Enter to send · Shift+Enter for a new line': 'Enter để gửi · Shift+Enter để xuống dòng', + 'Work in its own git worktree & branch': 'Làm trong thư mục và nhánh Git riêng', + 'Also work in': 'Làm thêm trong dự án', 'Other projects to work in': 'Các dự án làm cùng', + 'Provider': 'Nền tảng AI', 'Worker provider': 'Nền tảng của AI', 'Model': 'Model', + 'Effort': 'Mức suy luận', 'Default': 'Mặc định', 'Low': 'Thấp', 'Medium': 'Vừa', + 'High': 'Cao', 'Extra high': 'Rất cao', 'Max': 'Tối đa', 'Use the default': 'Dùng mặc định', + 'Use the default worker': 'Dùng AI mặc định', 'Default worker': 'AI mặc định', + 'Default (Codex settings)': 'Mặc định theo cài đặt Codex', + 'This model has no reasoning effort to pick': 'Model này không có tùy chọn mức suy luận', + 'starting': 'đang khởi động', 'ready': 'sẵn sàng', 'working': 'đang làm', + 'needs input': 'chờ anh phản hồi', 'done': 'đã xử lý xong', 'exited': 'đã thoát', 'asleep': 'đang dừng', + 'Reconnecting…': 'Đang kết nối lại…', 'Connecting…': 'Đang kết nối…', 'Loading…': 'Đang tải…', + 'No workers here.': 'Chưa có AI trong dự án này.', 'No workers yet': 'Chưa có AI nào', + 'No floors yet': 'Chưa có dự án', 'Add a project from the elevator in the 3D office.': 'Thêm dự án từ mục Chuyển dự án trong văn phòng 3D.', + 'Your name': 'Tên của anh', 'Password': 'Mật khẩu', 'Come on in': 'Vào văn phòng', + "Knock knock. What's the password?": 'Nhập mật khẩu để vào văn phòng.', + 'Knock knock. Who is it?': 'Anh đăng nhập bằng tài khoản nào?', + 'Knock knock. Who is it? Sign in with your own account.': 'Đăng nhập bằng tài khoản của anh.', + 'Could not sign in': 'Không đăng nhập được', 'Server unreachable': 'Không kết nối được máy chủ', + 'Wrong name or password': 'Sai tên hoặc mật khẩu', 'Wrong password': 'Sai mật khẩu', + 'Too many attempts. Try again in a few minutes.': 'Đã thử quá nhiều lần. Anh chờ vài phút rồi thử lại.', + 'Came in with the shared office password? Leave your name blank.': 'Dùng mật khẩu chung của văn phòng thì để trống tên.', + 'Who is it?': 'Anh tên gì?', 'Your teammates see this name on what you type and send.': 'Mọi người sẽ thấy tên này khi anh gửi nội dung.', + 'Pick your character': 'Chọn nhân vật của anh', 'Drag to spin': 'Kéo để xoay nhân vật', + 'Skin tone': 'Màu da', 'Hair': 'Kiểu tóc', 'Short': 'Ngắn', 'Long': 'Dài', 'Bun': 'Búi tóc', + 'Spiky': 'Tóc dựng', 'Curly': 'Tóc xoăn', 'Ponytail': 'Đuôi ngựa', 'Bald': 'Không tóc', + 'Hair color': 'Màu tóc', 'Shirt': 'Màu áo', 'Surprise me': 'Chọn ngẫu nhiên', + 'Enter the office 🚪': 'Vào văn phòng 🚪', 'Your character': 'Nhân vật của anh', + 'Change your look': 'Đổi ngoại hình', 'Change your look & name': 'Đổi ngoại hình và tên', + 'Camera view': 'Góc nhìn camera', 'First person': 'Góc nhìn thứ nhất', 'Third person': 'Góc nhìn thứ ba', + 'See through your own eyes. Click the office to look around with the mouse and click things to use them. Esc frees the mouse.': 'Nhìn từ mắt nhân vật. Bấm vào văn phòng để nhìn quanh bằng chuột và tương tác. Nhấn Esc để thả chuột.', + 'Follow your character from behind. Drag to orbit the camera, scroll to zoom, and click things to use them.': 'Nhìn nhân vật từ phía sau. Kéo để xoay góc nhìn, cuộn để phóng to/thu nhỏ và bấm vào đồ vật để tương tác.', + 'Just you': 'Chỉ anh', 'This floor': 'Dự án này', 'Everyone': 'Mọi người', + 'Only for you, kept in this browser': 'Chỉ áp dụng cho anh và lưu trong trình duyệt này', + 'The same for everyone on this floor': 'Áp dụng cho mọi người trong dự án này', + 'The same for everyone in the building': 'Áp dụng cho cả văn phòng', + 'Signed in': 'Đã đăng nhập', 'Sign out': 'Đăng xuất', + 'With the shared office password.': 'Đang dùng mật khẩu chung của văn phòng.', + 'Sound & voice': 'Âm thanh và thoại', 'Notifications': 'Thông báo', 'Building': 'Không gian văn phòng', + 'How you look, how you see the office, and how you’re signed in.': 'Ngoại hình nhân vật, góc nhìn và tài khoản đăng nhập.', + 'How loud the office is for you, and how voice chat works.': 'Âm lượng văn phòng và cách trò chuyện bằng giọng nói.', + 'Hear about a worker that needs someone, or finished, while you’re somewhere else.': 'Nhận thông báo khi AI cần phản hồi hoặc đã xử lý xong.', + 'The map, the decorations, the sky, the dog, and where new floors are cloned.': 'Bản đồ, trang trí, bầu trời, thú cưng và thư mục lưu dự án mới.', + 'What workers start on, how many run at once, when they go home and what the office tells them.': 'Model mặc định, số AI chạy cùng lúc, thời điểm dừng và hướng dẫn giao việc.', + 'Office sounds': 'Âm thanh văn phòng', 'Office sounds volume': 'Âm lượng văn phòng', + 'When a worker needs you': 'Khi AI cần anh', 'Ring once': 'Báo một lần', 'Keep reminding me': 'Tiếp tục nhắc', + 'Page turns at the bookshelf': 'Âm thanh lật trang', 'Jukebox': 'Nhạc văn phòng', 'Jukebox volume': 'Âm lượng nhạc', + 'Voice chat': 'Trò chuyện thoại', 'Open mic': 'Mở micro', 'Push to talk': 'Giữ phím để nói', + 'On': 'Bật', 'Off': 'Tắt', 'Desktop notifications': 'Thông báo trên máy tính', + 'Team notifications (Slack / Discord)': 'Thông báo nhóm (Slack / Discord)', + 'Map': 'Bản đồ', 'Holiday theme': 'Trang trí theo dịp', 'By the calendar': 'Theo lịch', + 'Christmas': 'Giáng sinh', 'Office dog': 'Thú cưng văn phòng', 'Outside': 'Bầu trời và thời tiết', + 'Workspace folder': 'Thư mục dự án', 'Most workers at once': 'Số AI tối đa cùng chạy', + 'Set limit': 'Đặt giới hạn', 'No limit': 'Không giới hạn', 'Workers whose pull request merged': 'AI có yêu cầu gộp mã đã được duyệt', + 'Go home by themselves': 'Tự dừng khi xong', 'Stay until sent home': 'Chờ đến khi được yêu cầu dừng', + 'Edit the prompts…': 'Sửa hướng dẫn giao việc…', 'Read the prompts…': 'Đọc hướng dẫn giao việc…', + 'Terminal font size': 'Cỡ chữ cửa sổ lệnh', 'Test': 'Thử', + 'Filter…': 'Lọc…', 'Search…': 'Tìm kiếm…', 'Task': 'Công việc', 'Status': 'Trạng thái', + 'Branch': 'Nhánh Git', 'Repository': 'Kho mã', 'Project': 'Dự án', 'Refresh': 'Làm mới', + 'Resume': 'Tiếp tục phiên', 'Restart': 'Khởi động lại', 'Stop': 'Dừng', 'Retry': 'Thử lại', + 'All': 'Tất cả', 'Running': 'Đang chạy', 'Waiting': 'Đang chờ', 'Completed': 'Đã hoàn thành', + 'Nothing here yet': 'Chưa có nội dung', 'No results': 'Không có kết quả', + 'Issues and tasks waiting for a worker': 'Các công việc đang chờ AI thực hiện', + 'Web servers the workers are running': 'Các dịch vụ web do AI khởi chạy', + 'Draw together, live': 'Cùng vẽ trực tiếp', 'Search the chat and every terminal': 'Tìm trong trò chuyện và các cửa sổ lệnh', + 'Read the project’s docs': 'Đọc tài liệu dự án', 'Ride to another project': 'Chuyển sang dự án khác', + 'Go to another project, or add one': 'Chuyển hoặc thêm dự án', + 'Call a meeting: workers work through a question or a task together': 'Tạo cuộc họp để các AI cùng xử lý một câu hỏi hoặc công việc', + 'Hide this terminal; the worker keeps running at its desk': 'Ẩn cửa sổ này; AI vẫn tiếp tục chạy tại bàn', + 'Hide terminal (Esc or Ctrl+]) · the worker keeps running; ⎋ Esc or Ctrl+[ sends Esc to the terminal': 'Ẩn cửa sổ: Esc hoặc Ctrl+]. AI vẫn chạy. Nút ⎋ Esc hoặc Ctrl+[ gửi phím Esc vào chương trình.', + 'Isolate this worker on its own branch so parallel workers never collide': 'Tách thư mục và nhánh riêng để các AI không sửa đè nhau', + 'Opening the doors': 'Đang mở văn phòng', 'Knocking on the door': 'Đang kết nối văn phòng', + 'Fetching the dog': 'Đang tải thú cưng', 'Loading models': 'Đang tải mô hình đồ họa', + 'SHOW ON SCREEN': 'HIỆN TRÊN MÀN HÌNH', 'Show on screen': 'Hiện trên màn hình', + 'Spend': 'Chi phí', 'Today, the budget, all time': 'Hôm nay, ngân sách và tổng cộng', + 'Claude limits': 'Hạn mức Claude', 'The plan’s 5-hour and week': 'Hạn mức 5 giờ và theo tuần', + 'Chat': 'Trò chuyện', 'T opens it either way': 'Nhấn T để mở trò chuyện', + 'Floor details': 'Thông tin dự án', 'Branch, folder, default agent': 'Nhánh Git, thư mục và AI mặc định', + 'Every desk and what it’s up to': 'Các bàn và công việc đang xử lý', + 'Who’s here, on which floor': 'Ai đang ở đây và thuộc dự án nào', + 'Pin what you use most to keep it on the top bar. Tab opens and closes this menu.': 'Ghim mục thường dùng lên thanh trên cùng. Nhấn Tab để mở hoặc đóng menu.', + 'Click to look around': 'Bấm để nhìn quanh', 'Get up': 'Đứng dậy', 'First Officer': 'AI điều phối', + 'Speak to me to bring a new worker aboard': 'Giao yêu cầu để tạo AI thực hiện mới', + 'Send out a new worker': 'Giao việc cho AI mới', + 'Open the terminal: complete login and review Office hooks in /hooks': 'Mở cửa sổ lệnh để hoàn tất đăng nhập và kiểm tra hook văn phòng tại /hooks', + 'NEEDS YOU': 'CHỜ ANH PHẢN HỒI', 'waiting for first report': 'đang chờ báo cáo đầu tiên', + 'waiting for usage report': 'đang chờ báo cáo sử dụng', 'No task yet': 'Chưa có công việc', + 'No workers': 'Chưa có AI', 'Send prompt': 'Gửi yêu cầu', 'View terminal': 'Xem cửa sổ lệnh', + 'New worker': 'Tạo AI mới', 'Task name': 'Tên công việc', 'Instructions': 'Hướng dẫn', +}; + +export function vietnameseLabel(value: string): string { + const plain = value.trim(); + let translated = VI[plain]; + if (!translated) { + // Preserve emoji prefixes and keyboard shortcut suffixes on otherwise exact UI labels. + for (const [en, vi] of Object.entries(VI)) { + if (plain.endsWith(en) && /^[^\p{L}\p{N}]*$/u.test(plain.slice(0, -en.length))) { + translated = plain.slice(0, -en.length) + vi; break; + } + } + } + if (!translated) translated = plain.replace(/^✨ Hire a worker at Desk (\d+)$/, '✨ Tạo AI tại bàn $1'); + if (translated === plain) translated = plain.replace(/^(\D*)(\d+) needs you$/, '$1$2 AI đang chờ anh').replace(/^(.*) needs you$/, '$1 đang chờ anh'); + if (translated === plain && plain.includes(' · ')) { + translated = plain.split(' · ').map(part => VI[part] ?? part).join(' · '); + } + if (translated === plain) return value; + if (!translated) return value; + return value.replace(plain, translated); +} diff --git a/tests/vi-labels.test.ts b/tests/vi-labels.test.ts new file mode 100644 index 000000000..e46f96034 --- /dev/null +++ b/tests/vi-labels.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { vietnameseLabel } from '../src/client/shared/vi-labels'; + +test('Vietnamese UI labels keep icons and variable desk numbers', () => { + assert.equal(vietnameseLabel('⚙️ Settings'), '⚙️ Cài đặt'); + assert.equal(vietnameseLabel('✨ Hire a worker at Desk 8'), '✨ Tạo AI tại bàn 8'); + assert.equal(vietnameseLabel(' Workers '), ' Nhóm AI '); +}); +test('Unknown labels, commands and model identifiers stay intact', () => { + for (const text of ['Pixel', 'Get-Location', 'gpt-6.1-sol', 'C:\\my-project', 'worker.start']) { + assert.equal(vietnameseLabel(text), text); + } +});