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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions docs/controls.md
Original file line number Diff line number Diff line change
@@ -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 |
Expand Down
1 change: 1 addition & 0 deletions src/client/lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/client/login.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/client/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
68 changes: 68 additions & 0 deletions src/client/shared/locale-vi.ts
Original file line number Diff line number Diff line change
@@ -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<Node>();
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();
139 changes: 139 additions & 0 deletions src/client/shared/vi-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/** Vietnamese interface labels. Provider/model identifiers and terminal output are never translated. */
export const VI: Record<string, string> = {
'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);
}
14 changes: 14 additions & 0 deletions tests/vi-labels.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});