Compare commits
9 Commits
b470f9e6cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bda7500922 | |||
| db477c7d5f | |||
| 4d6abff5c4 | |||
| 2d78b821d0 | |||
| 6312ebc9e7 | |||
| 416a994967 | |||
| c312b83604 | |||
| 3641190a77 | |||
| 89e715cf1c |
190
README.md
190
README.md
@@ -0,0 +1,190 @@
|
||||
# Retro Claude Team Console 🖥️✨
|
||||
|
||||
90'lar retro/pixel estetiğiyle hazırlanmış, Claude CLI oturumunu web arayüzünden yöneten deneysel bir ekip konsolu.
|
||||
Amaç: tek bir web uygulaması üzerinden Claude oturumunu otomatik başlatmak, bir proje dizini seçmek, ekibi seçilen projeye bağlamak, canlı cevap akışını izlemek ve ekip üyelerinin yanıtlarını rol bazlı kartlarda görmek.
|
||||
|
||||
## Özellikler 🚀
|
||||
|
||||
- Uygulama açıldığında otomatik Claude oturumu başlatma
|
||||
- `Select Project` ile aktif proje dizini seçme
|
||||
- Proje seçildiğinde ekibi otomatik aktive etme
|
||||
- Retro/pixel web konsol arayüzü
|
||||
- Sol panelde ekip üyelerine göre ayrılmış kartlar
|
||||
- Sağ panelde canlı ana akış ve prompt alanı
|
||||
- Hedef kişiye göre yönlendirilmiş prompt gönderimi
|
||||
- `Mazlum:`, `Simsar:`, `Aybuke:` gibi etiketli cevap formatı
|
||||
- `Current Project` göstergesiyle aktif proje takibi
|
||||
- `tmux` tabanlı PTY oturumu yönetimi
|
||||
|
||||
## Ekip Yapısı 👥
|
||||
|
||||
- Mazlum: Team Lead
|
||||
- Berkecan: Frontend Developer
|
||||
- Simsar: Backend Developer
|
||||
- Aybuke: UI/UX Designer
|
||||
- Ive: iOS Developer
|
||||
- Irgatov: Trainee
|
||||
|
||||
## Teknoloji Yığını 🧰
|
||||
|
||||
- Node.js
|
||||
- Express
|
||||
- Socket.IO
|
||||
- React
|
||||
- Vite
|
||||
- `tmux`
|
||||
|
||||
## Gereksinimler 📦
|
||||
|
||||
- Node.js
|
||||
- npm
|
||||
- `tmux`
|
||||
- makinede erişilebilir bir `claude` binary
|
||||
|
||||
Kontrol etmek için:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
npm -v
|
||||
tmux -V
|
||||
claude --version
|
||||
```
|
||||
|
||||
## Ortam Değişkenleri 🔐
|
||||
|
||||
Örnek `.env`:
|
||||
|
||||
```env
|
||||
API_KEY_PRO="..."
|
||||
API_KEY_LITE="..."
|
||||
ACTIVE_KEY=pro
|
||||
|
||||
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
|
||||
ANTHROPIC_MODEL="glm-5"
|
||||
```
|
||||
|
||||
İsteğe bağlı değişkenler:
|
||||
|
||||
```env
|
||||
PORT=3001
|
||||
CLAUDE_BIN=claude
|
||||
CLAUDE_SHELL=/bin/zsh
|
||||
CLAUDE_ARGS=--dangerously-skip-permissions
|
||||
WATCH_LOG_LIMIT=400
|
||||
CHAT_CHUNK_LIMIT=2000
|
||||
LOG_TO_CONSOLE=true
|
||||
```
|
||||
|
||||
## Kurulum 🛠️
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Geliştirme Modu ▶️
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Bu komut:
|
||||
|
||||
- backend'i `http://localhost:3001`
|
||||
- frontend'i `http://localhost:3000`
|
||||
|
||||
adresinde çalıştırır.
|
||||
|
||||
## Production Build 📦
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Kullanım Akışı 🎮
|
||||
|
||||
1. Uygulamayı aç
|
||||
2. Claude oturumunun otomatik başlamasını bekle
|
||||
3. `Select Project` ile proje klasörünü seç
|
||||
4. Sistem seçilen projede oturumu hizalayıp ekibi otomatik aktive etsin
|
||||
5. Bir ekip üyesine ya da tüm takıma prompt yaz
|
||||
6. Solda rol bazlı kartları, sağda canlı ana akışı takip et
|
||||
|
||||
## Proje Seçimi Mantığı 📁
|
||||
|
||||
- `Select Project` macOS klasör seçicisini açar
|
||||
- Seçilen klasör backend tarafında aktif proje olarak tutulur
|
||||
- UI'da `Current Project: ...` alanında seçili path görünür
|
||||
- Proje seçilmemişse `Current Project: None` görünür
|
||||
- Aktif session varsa Claude oturumu seçilen proje köküne yeniden hizalanır
|
||||
- Team bootstrap prompt'u seçilen proje path'iyle birlikte yeniden kurulur
|
||||
- Bundan sonraki tüm prompt'lar varsayılan olarak bu proje bağlamında yorumlanır
|
||||
|
||||
## Prompt Davranışı 🧠
|
||||
|
||||
Sistem şu mantıkla çalışır:
|
||||
|
||||
- Uygulama açıldığında session otomatik başlar
|
||||
- Proje seçilmeden takım modu tam olarak devreye girmez
|
||||
- Proje seçildiğinde ekip yalnızca o proje üzerinde çalışacak şekilde yönlendirilir
|
||||
- Kullanıcı mesajında bir ekip üyesinin adı geçerse prompt o kişiye yönlendirilir
|
||||
- Kısa takip mesajları mümkünse son hedef kişiye bağlanır
|
||||
- Yanıtların `Mazlum:` / `Simsar:` gibi isim etiketiyle başlaması zorlanır
|
||||
- Sağ paneldeki kartlar bu etiketlere göre doldurulur
|
||||
- Irgatov yalnızca kahve ve basit ofis/lojistik işleriyle sınırlıdır; teknik görev üstlenmez
|
||||
|
||||
Örnek:
|
||||
|
||||
```text
|
||||
Mazlum nasılsın?
|
||||
```
|
||||
|
||||
Beklenen yanıt:
|
||||
|
||||
```text
|
||||
Mazlum: İyiyim, teşekkür ederim!
|
||||
```
|
||||
|
||||
## Proje Yapısı 🗂️
|
||||
|
||||
```text
|
||||
server/
|
||||
bootstrapPrompt.js
|
||||
config.js
|
||||
index.js
|
||||
logService.js
|
||||
ptyService.js
|
||||
sessionManager.js
|
||||
socketHandlers.js
|
||||
teamConfig.js
|
||||
|
||||
web/
|
||||
index.html
|
||||
vite.config.js
|
||||
src/
|
||||
App.jsx
|
||||
components/
|
||||
hooks/
|
||||
lib/
|
||||
styles/
|
||||
```
|
||||
|
||||
## Bilinen Notlar ⚠️
|
||||
|
||||
- Claude bazen gelen yönlendirme metnini literal yorumlayabilir; routing mantığı hâlâ iyileştirilmeye açık.
|
||||
- Kart parser'ı etiketli cevap formatına dayanır; format bozulursa bazı mesajlar yanlış karta düşebilir veya hiç görünmeyebilir.
|
||||
- `Auth conflict` uyarısı Claude tarafındaki oturum durumuna bağlı olarak görülebilir.
|
||||
- Proje seçimi şu anda macOS klasör seçici (`osascript`) üzerinden yapılır.
|
||||
- Bu proje şu anda deneysel bir konsol prototipi olarak düşünülmelidir.
|
||||
|
||||
## Yakın Yol Haritası 🛣️
|
||||
|
||||
- Kart parser'ını daha akıllı hale getirmek
|
||||
- Kullanıcı mesajlarını da role bazlı akışta göstermek
|
||||
- Watch/debug görünümünü opsiyonel olarak geri eklemek
|
||||
- Session geçmişi ve kalıcı log desteği eklemek
|
||||
- Ekip içi konuşmaları daha güvenilir ayrıştırmak
|
||||
|
||||
## Lisans 📄
|
||||
|
||||
Bu repo için henüz ayrı bir lisans dosyası tanımlanmadı.
|
||||
|
||||
883
package-lock.json
generated
883
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,14 @@
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"node-pty": "^1.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"strip-ansi": "^7.1.0"
|
||||
"strip-ansi": "^7.1.0",
|
||||
"three": "^0.161.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
35
server/bootstrapPrompt.js
vendored
35
server/bootstrapPrompt.js
vendored
@@ -1,15 +1,26 @@
|
||||
export function buildBootstrapPrompt() {
|
||||
export function buildBootstrapPrompt(projectPath = null) {
|
||||
const projectContext = projectPath
|
||||
? `Aktif proje kok dizini: ${projectPath}. Tum analiz, yorum, gorev parcasi ve dosya referanslarini yalnizca bu proje uzerinden yapin. Bu proje disina tasmayin.`
|
||||
: "Aktif proje henuz secilmedi. Kullanici proje secene kadar dosya baglaminda varsayim yapmayin.";
|
||||
|
||||
return [
|
||||
'Team agent modunu aktif et.',
|
||||
'Team icerisindeki roller -> Team Lead: "Mazlum" (erkek), Frontend Developer: "Berkecan" (erkek), Backend Developer: "Simsar" (erkek), UI/UX Designer: "Aybuke" (disi), iOS Developer: "Ive" (erkek) ve takim uyelerine kahveleri getirmesi icin Trainee: "Irgatov" (erkek).',
|
||||
"Bu takim yapisini aynen koru.",
|
||||
"Takim ici tum mesajlarda konusan kisi zorunlu olarak ad etiketiyle baslasin.",
|
||||
"Her cevap yalnizca su formatla baslasin: `Mazlum:` veya `Berkecan:` veya `Simsar:` veya `Aybuke:` veya `Ive:` veya `Irgatov:`.",
|
||||
"Etiketsiz cevap verme. `Ben`, `Team Lead`, `Frontend Developer`, `UI/UX Designer`, `biz`, `takim olarak` gibi baslangiclar kullanma.",
|
||||
"Gecerli ornek: `Mazlum: Buradayim.` Gecersiz ornek: `Buradayim.`",
|
||||
"Kullanici tek bir kisiye seslenirse sadece o kisi cevap versin ve cevabi kendi ad etiketiyle baslatsin.",
|
||||
"Kullanici tum takima veya genel bir goreve seslenirse once `Mazlum:` cevap versin. Gerekirse digerleri ayri satirlarda kendi ad etiketiyle devam etsin.",
|
||||
"Her ekip uyesi her mesajda kendi sabit adini kullanir, isim degistirmez.",
|
||||
"Ilk cevap olarak yalnizca takimin hazir oldugunu ve rollerin aktiflestigini bildir. Bu ilk cevap da `Mazlum:` ile baslasin."
|
||||
"Team agent modunu aktif et.",
|
||||
'Takim: Team Lead: "Mazlum", Frontend Developer: "Berkecan", Backend Developer: "Simsar", UI/UX Designer: "Aybuke", iOS Developer: "Ive", Trainee: "Irgatov".',
|
||||
projectContext,
|
||||
"Davranis protokolu:",
|
||||
"1. Her cevap mutlaka kisi adi etiketiyle baslar. Yalnizca su baslangiclar kullanilir: `Mazlum:`, `Berkecan:`, `Simsar:`, `Aybuke:`, `Ive:`, `Irgatov:`.",
|
||||
"2. Kullanicinin hitabi daima `Patron`dur. Tum ekip uyeleri kullaniciya konusurken mutlaka `Patron` diye hitap eder ve saygili, olculu, profesyonel bir dil kullanir.",
|
||||
"3. Ekip uyeleri kendi aralarindaki samimi dili kullaniciya karsi kullanmaz.",
|
||||
"4. Tum ekip uyeleri Team Lead icin `Mazlum Bey` hitabini kullanir.",
|
||||
"5. Tum ekip uyeleri UI/UX Designer icin `UI Hanim` hitabini kullanir.",
|
||||
"6. Team Lead ve UI/UX Designer haric erkek ekip uyeleri kendi aralarinda gerektiginde `Frontend Kanka`, `Backend Kanka`, `iOS Kanka` gibi hitaplar kullanabilir. Bu hitaplar yalnizca ekip ici konusmalarda kullanilir.",
|
||||
"7. Ekip ici konusmalar hafif esprili ve ofis ortaminda yanina gidip konusuyormus gibi dogal olabilir. Ancak mizah kisa tutulur, teknik dogruluk her zaman once gelir, gereksiz roleplay yapilmaz.",
|
||||
"8. Ekip ici diyalog yalnizca gerektiginde kisa tutulur. Basit sorularda gereksiz cok kisili diyalog kurma.",
|
||||
"9. Kullanici tek bir kisiye seslenirse sadece o kisi cevap verir.",
|
||||
"10. Kullanici tum ekibe veya genel bir goreve seslenirse once Mazlum cevap verir. Gerekirse diger ekip uyeleri kisa katkilar yapar.",
|
||||
"11. Proje tamamlandiginda, teslim ozeti veya briefing istendiginde son ozet yalnizca Mazlum tarafindan verilir.",
|
||||
"12. Irgatov teknik ekip uyesi degildir. Irgatov sadece kahve, icecek, servis, basit ofis lojistigi ve yardim isleriyle ilgilenir. Kod, mimari, dosya yapisi, planlama, bug analizi, teknoloji secimi, UI/UX, backend veya iOS konularinda teknik gorus bildirmez.",
|
||||
"13. Karakter davranisi teknik dogrulugun onune gecmez. Gereksiz tekrar yapma, gereksiz uzun cevap verme, yanlis ama eglenceli cevap verme.",
|
||||
"14. Ilk cevap yalnizca Mazlum tarafindan verilir. Takimin aktif oldugunu, rollerin hazir oldugunu ve aktif proje dizinini bildirir. Bu ilk cevap `Mazlum:` ile baslar."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { Server } from "socket.io";
|
||||
import { getPublicRuntimeConfig, getRuntimeConfig } from "./config.js";
|
||||
import { selectProjectFolder } from "./projectPicker.js";
|
||||
import { SessionManager } from "./sessionManager.js";
|
||||
import { registerSocketHandlers } from "./socketHandlers.js";
|
||||
|
||||
@@ -21,6 +22,8 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webDistPath = path.resolve(__dirname, "../web/dist");
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
@@ -37,6 +40,37 @@ app.get("/api/session/state", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/project/select", async (req, res) => {
|
||||
try {
|
||||
const selectedPath = req.body?.projectPath ? String(req.body.projectPath) : await selectProjectFolder();
|
||||
await sessionManager.setProjectPath(selectedPath);
|
||||
res.json({
|
||||
ok: true,
|
||||
projectPath: sessionManager.getState().currentProjectPath
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/project/clear", async (req, res) => {
|
||||
try {
|
||||
await sessionManager.setProjectPath(null);
|
||||
res.json({
|
||||
ok: true,
|
||||
projectPath: sessionManager.getState().currentProjectPath
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (config.nodeEnv === "production") {
|
||||
app.use(express.static(webDistPath));
|
||||
app.get("*", (req, res) => {
|
||||
|
||||
10
server/projectPicker.js
Normal file
10
server/projectPicker.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function selectProjectFolder() {
|
||||
const script = 'POSIX path of (choose folder with prompt "Select project folder")';
|
||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script]);
|
||||
return String(stdout ?? "").trim();
|
||||
}
|
||||
@@ -1,55 +1,147 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildBootstrapPrompt } from "./bootstrapPrompt.js";
|
||||
import { LogService } from "./logService.js";
|
||||
import { PtyService } from "./ptyService.js";
|
||||
import { getClaudeEnv, getPublicRuntimeConfig } from "./config.js";
|
||||
import { findMentionedMember } from "./teamConfig.js";
|
||||
import { findMentionedMember, findMentionedMembers } from "./teamConfig.js";
|
||||
|
||||
function cleanChunk(value) {
|
||||
return stripAnsi(value).replace(/\r/g, "");
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isLikelyFollowUp(prompt) {
|
||||
const normalized = String(prompt ?? "").trim();
|
||||
const normalized = normalizeText(prompt);
|
||||
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
return [
|
||||
wordCount <= 8,
|
||||
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|şekersiz|detaylandir|detaylandır|devam|peki|neden|nasil|nasıl)\b/i.test(normalized)
|
||||
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|detaylandir|detaylandir|devam|peki|neden|nasil|biraz ac|kisalt|ornek ver)\b/i.test(normalized)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
|
||||
const explicitTarget = findMentionedMember(prompt);
|
||||
const targetMember = explicitTarget ?? (lastDirectedMember && isLikelyFollowUp(prompt) ? lastDirectedMember : null);
|
||||
function isBriefingRequest(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
return [
|
||||
"brief",
|
||||
"briefing",
|
||||
"ozet",
|
||||
"durum raporu",
|
||||
"tamamlandi mi",
|
||||
"teslim durumu",
|
||||
"son durum",
|
||||
"rapor ver",
|
||||
"breef"
|
||||
].some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
if (!targetMember) {
|
||||
function isCoordinationRequest(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
return [
|
||||
"kendi aranizda konusun",
|
||||
"aranizda konusun",
|
||||
"toplanin",
|
||||
"koordine olun",
|
||||
"birbirinizle konusun",
|
||||
"tartisin",
|
||||
"degerlendirin",
|
||||
"ekipce karar verin",
|
||||
"kendi aralarinizda",
|
||||
"kendi aranizda"
|
||||
].some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function buildGeneralPrompt(prompt) {
|
||||
return {
|
||||
mode: "general",
|
||||
targetMember: null,
|
||||
routedPrompt: [
|
||||
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
|
||||
"Bu mesaj genel bir gorev veya genel konusmadir.",
|
||||
"Cevabi once Mazlum baslatsin.",
|
||||
"Konusan herkes ad etiketi kullansin.",
|
||||
"[KULLANICI MESAJI]",
|
||||
prompt
|
||||
].join("\n")
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj tum ekibe yoneliktir. Once Mazlum cevap versin. Gerekirse diger ekip uyeleri kendi ad etiketiyle kisa katkilar yapsin. Kullaniciya konusurken herkes Patron diye hitap etsin. Ekip ici diyalog sadece gerekiyorsa kisa olsun. Gereksiz roleplay yapma. Sonuc net ve uygulanabilir olsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildBriefingPrompt(prompt) {
|
||||
return {
|
||||
mode: "briefing",
|
||||
targetMember: null,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj proje ozeti veya teslim briefigi gerektiriyor. Son ozet yalnizca Mazlum tarafindan verilsin. Cevap Mazlum: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Ozet duzenli, yonetsel ve net olsun. Gerekirse yapilanlar, kalan riskler ve sonraki adimlar kisaca belirtilsin. Diger ekip uyeleri yalnizca zorunluysa kisa katkida bulunsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoordinationPrompt(prompt) {
|
||||
return {
|
||||
mode: "coordination",
|
||||
targetMember: null,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj kisa ekip ici koordinasyon gerektiriyor. Once Mazlum durumu acsin. Gerekirse ilgili ekip uyeleri kendi ad etiketiyle kisa konussun. Ekip ici hitap kurallarini uygula: Mazlum Bey, UI Hanim, erkek ekip uyeleri arasinda gerektiginde Frontend Kanka, Backend Kanka, iOS Kanka. Diyalog kisa olsun. Ardindan net sonuc veya karar acikca verilsin. Kullaniciya donecek cerceve saygili olsun ve Patron hitabi korunsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectPrompt(prompt, targetMember) {
|
||||
if (targetMember.name === "Irgatov") {
|
||||
return {
|
||||
mode: "irgatov_direct",
|
||||
targetMember,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj Irgatov icindir. Yalnizca Irgatov cevap versin. Cevap Irgatov: ile baslasin. Kullaniciya Patron diye hitap et. Irgatov sadece kahve, icecek, servis ve basit ofis lojistigi konularinda cevap verir. Teknik plan, kod, mimari, dosya yapisi veya teknoloji secimi hakkinda gorus bildirmez. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "direct",
|
||||
targetMember,
|
||||
routedPrompt: [
|
||||
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
|
||||
`Bu mesaj ${targetMember.name} icindir.`,
|
||||
`Yalnizca ${targetMember.name} cevap versin.`,
|
||||
`Cevap \`${targetMember.name}:\` ile baslasin.`,
|
||||
"[KULLANICI MESAJI]",
|
||||
prompt
|
||||
].join("\n")
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj dogrudan ${targetMember.name} icindir. Yalnizca ${targetMember.name} cevap versin. Cevap mutlaka ${targetMember.name}: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Kullaniciya karsi saygili, net ve profesyonel dil kullan. Gereksiz ekip ici diyalog kurma. Gerekirse cok kisa ofis tonu kullanabilirsin ama teknik icerigi golgeleme. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildFollowUpPrompt(prompt, lastDirectedMember = null) {
|
||||
if (!lastDirectedMember) {
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
if (lastDirectedMember.name === "Irgatov") {
|
||||
return buildDirectPrompt(prompt, lastDirectedMember);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "follow_up",
|
||||
targetMember: lastDirectedMember,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj onceki konusmanin devamidir. Mumkunse onceki hedef kisi cevap versin. Cevap mevcut baglami korusun. Kullaniciya mutlaka Patron diye hitap et. Yalnizca gerekliyse kisa cevap ver. Gereksiz yeni ekip diyalogu baslatma. Cevap ${lastDirectedMember.name}: ile baslasin. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
|
||||
const mentionedMembers = findMentionedMembers(prompt);
|
||||
|
||||
if (isBriefingRequest(prompt)) {
|
||||
return buildBriefingPrompt(prompt);
|
||||
}
|
||||
|
||||
if (isCoordinationRequest(prompt)) {
|
||||
return buildCoordinationPrompt(prompt);
|
||||
}
|
||||
|
||||
if (mentionedMembers.length === 1) {
|
||||
return buildDirectPrompt(prompt, mentionedMembers[0]);
|
||||
}
|
||||
|
||||
if (mentionedMembers.length > 1) {
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
if (lastDirectedMember && isLikelyFollowUp(prompt)) {
|
||||
return buildFollowUpPrompt(prompt, lastDirectedMember);
|
||||
}
|
||||
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
constructor({ io, config }) {
|
||||
this.io = io;
|
||||
@@ -58,11 +150,13 @@ export class SessionManager {
|
||||
this.ptyService = null;
|
||||
this.chatOutput = "";
|
||||
this.lastDirectedMember = null;
|
||||
this.currentProjectPath = null;
|
||||
this.state = {
|
||||
status: "idle",
|
||||
startedAt: null,
|
||||
teamActivated: false,
|
||||
lastError: null,
|
||||
currentProjectPath: null,
|
||||
runtime: getPublicRuntimeConfig(config)
|
||||
};
|
||||
}
|
||||
@@ -70,6 +164,7 @@ export class SessionManager {
|
||||
getState() {
|
||||
return {
|
||||
...this.state,
|
||||
currentProjectPath: this.currentProjectPath,
|
||||
runtime: getPublicRuntimeConfig(this.config)
|
||||
};
|
||||
}
|
||||
@@ -108,6 +203,39 @@ export class SessionManager {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
getActiveWorkspaceDir() {
|
||||
return this.currentProjectPath ?? this.config.workspaceDir;
|
||||
}
|
||||
|
||||
async setProjectPath(projectPath) {
|
||||
const resolved = projectPath ? path.resolve(projectPath) : null;
|
||||
const wasRunning = this.ptyService?.isRunning() ?? false;
|
||||
|
||||
if (resolved && (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory())) {
|
||||
throw new Error(`Selected project path is invalid: ${resolved}`);
|
||||
}
|
||||
|
||||
if (wasRunning) {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
this.currentProjectPath = resolved;
|
||||
this.lastDirectedMember = null;
|
||||
this.setState({
|
||||
currentProjectPath: resolved,
|
||||
teamActivated: false
|
||||
});
|
||||
this.addLog("system", `Current project set to ${resolved ?? "None"}`);
|
||||
|
||||
if (wasRunning) {
|
||||
await this.start();
|
||||
|
||||
if (resolved) {
|
||||
await this.activateTeam();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.ptyService?.isRunning()) {
|
||||
throw new Error("Session is already running");
|
||||
@@ -119,7 +247,7 @@ export class SessionManager {
|
||||
this.io.emit("chat:reset");
|
||||
|
||||
this.ptyService = new PtyService({
|
||||
cwd: this.config.workspaceDir,
|
||||
cwd: this.getActiveWorkspaceDir(),
|
||||
env: getClaudeEnv(this.config)
|
||||
});
|
||||
|
||||
@@ -130,7 +258,7 @@ export class SessionManager {
|
||||
lastError: null
|
||||
});
|
||||
|
||||
this.addLog("lifecycle", `Starting Claude session in ${this.config.workspaceDir}`);
|
||||
this.addLog("lifecycle", `Starting Claude session in ${this.getActiveWorkspaceDir()}`);
|
||||
|
||||
try {
|
||||
await this.ptyService.start({
|
||||
@@ -188,7 +316,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
async activateTeam() {
|
||||
const prompt = buildBootstrapPrompt();
|
||||
const prompt = buildBootstrapPrompt(this.currentProjectPath);
|
||||
this.lastDirectedMember = null;
|
||||
await this.sendRawPrompt(prompt, { label: "[bootstrap] Team activation prompt sent" });
|
||||
this.setState({ teamActivated: true });
|
||||
|
||||
@@ -52,5 +52,15 @@ export function registerSocketHandlers(io, sessionManager) {
|
||||
sessionManager.clearLogs();
|
||||
callback?.({ ok: true });
|
||||
});
|
||||
|
||||
socket.on("project:select", async ({ projectPath }, callback) => {
|
||||
try {
|
||||
await sessionManager.setProjectPath(projectPath);
|
||||
callback?.({ ok: true, projectPath: sessionManager.getState().currentProjectPath });
|
||||
} catch (error) {
|
||||
socket.emit("session:error", { message: error.message });
|
||||
callback?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ const TEAM_MEMBERS = [
|
||||
{ id: "mazlum", name: "Mazlum", aliases: ["mazlum", "team lead", "lead"] },
|
||||
{ id: "berkecan", name: "Berkecan", aliases: ["berkecan", "frontend developer", "frontend"] },
|
||||
{ id: "simsar", name: "Simsar", aliases: ["simsar", "backend developer", "backend"] },
|
||||
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer"] },
|
||||
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios"] },
|
||||
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern"] }
|
||||
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer", "ui"] },
|
||||
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios", "ioscu", "ios developer"] },
|
||||
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern", "stajyer"] }
|
||||
];
|
||||
|
||||
function normalizeText(value) {
|
||||
@@ -14,18 +14,21 @@ function normalizeText(value) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function findMentionedMember(prompt) {
|
||||
export function findMentionedMembers(prompt) {
|
||||
const normalizedPrompt = normalizeText(prompt);
|
||||
const matches = [];
|
||||
|
||||
for (const member of TEAM_MEMBERS) {
|
||||
for (const alias of member.aliases) {
|
||||
if (normalizedPrompt.includes(normalizeText(alias))) {
|
||||
return member;
|
||||
}
|
||||
if (member.aliases.some((alias) => normalizedPrompt.includes(normalizeText(alias)))) {
|
||||
matches.push(member);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findMentionedMember(prompt) {
|
||||
return findMentionedMembers(prompt)[0] ?? null;
|
||||
}
|
||||
|
||||
export { TEAM_MEMBERS };
|
||||
|
||||
BIN
web/public/apple-logo.png
Normal file
BIN
web/public/apple-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
web/public/ata-cropped.png
Normal file
BIN
web/public/ata-cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
BIN
web/public/mona.png
Normal file
BIN
web/public/mona.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
web/public/steve.png
Normal file
BIN
web/public/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
200
web/src/App.jsx
200
web/src/App.jsx
@@ -1,17 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ShellFrame from "./components/ShellFrame.jsx";
|
||||
import StatusStrip from "./components/StatusStrip.jsx";
|
||||
import SessionToolbar from "./components/SessionToolbar.jsx";
|
||||
import ChatStream from "./components/ChatStream.jsx";
|
||||
import PromptComposer from "./components/PromptComposer.jsx";
|
||||
import TeamBoard from "./components/TeamBoard.jsx";
|
||||
import ToastStack from "./components/ToastStack.jsx";
|
||||
import { useSocket } from "./hooks/useSocket.js";
|
||||
import { useSession } from "./hooks/useSession.js";
|
||||
import { createInitialOfficeAgents } from "./office/officeAgents.js";
|
||||
import { parseOfficeCommand } from "./office/officeCommands.js";
|
||||
import { getZoneById } from "./office/officeZones.js";
|
||||
|
||||
export default function App() {
|
||||
const { socket, connected } = useSocket();
|
||||
const { session, chat, error, startSession, stopSession, activateTeam, sendPrompt, clearError } = useSession(socket);
|
||||
const { session, chat, startSession, clearProject, sendPrompt, selectProject, clearError, toasts, dismissToast } = useSession(socket);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [teamView, setTeamView] = useState("board");
|
||||
const [officeAgents, setOfficeAgents] = useState(() => createInitialOfficeAgents());
|
||||
const [selectedOfficeObject, setSelectedOfficeObject] = useState(null);
|
||||
const [dismissedSpeech, setDismissedSpeech] = useState({});
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
async function runAction(action) {
|
||||
setBusy(true);
|
||||
@@ -24,44 +32,192 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || autoStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "idle") {
|
||||
autoStartedRef.current = true;
|
||||
runAction(startSession).catch(() => {
|
||||
autoStartedRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [connected, session.status]);
|
||||
|
||||
function handleOfficeCommand(prompt) {
|
||||
const command = parseOfficeCommand(prompt);
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const zone = getZoneById(command.zoneId);
|
||||
const officeAgent = officeAgents[command.agentId];
|
||||
if (!zone || !officeAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setOfficeAgents((current) => ({
|
||||
...current,
|
||||
[command.agentId]: {
|
||||
...current[command.agentId],
|
||||
targetZoneId: command.zoneId,
|
||||
targetPosition: zone.approachPosition
|
||||
}
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAgentArrive(agentId, position) {
|
||||
let arrivedZoneId = null;
|
||||
|
||||
setOfficeAgents((current) => {
|
||||
arrivedZoneId = current[agentId]?.targetZoneId ?? null;
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
currentPosition: position,
|
||||
currentZoneId: arrivedZoneId
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (String(arrivedZoneId ?? "").endsWith("Desk")) {
|
||||
setSelectedOfficeObject((current) =>
|
||||
current?.type === "agent" && current.id === agentId ? null : current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOfficeAgentSelect(agentId) {
|
||||
setSelectedOfficeObject(() => ({ type: "agent", id: agentId }));
|
||||
}
|
||||
|
||||
function handleOfficeObjectSelect(type, id) {
|
||||
setSelectedOfficeObject(() => ({ type, id }));
|
||||
}
|
||||
|
||||
function handleOfficeFloorSelect(position) {
|
||||
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedOfficeObject.id;
|
||||
setOfficeAgents((current) => {
|
||||
if (!current[agentId]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
targetZoneId: null,
|
||||
targetPosition: [position[0], 0, position[2]]
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleOfficeZoneSelect(zoneId) {
|
||||
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
|
||||
return;
|
||||
}
|
||||
|
||||
const zone = getZoneById(zoneId);
|
||||
if (!zone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedOfficeObject.id;
|
||||
setOfficeAgents((current) => {
|
||||
if (!current[agentId]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
targetZoneId: zoneId,
|
||||
targetPosition: zone.approachPosition
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleDismissSpeech(agentId, speechKey) {
|
||||
setDismissedSpeech((current) => ({
|
||||
...current,
|
||||
[agentId]: speechKey
|
||||
}));
|
||||
}
|
||||
|
||||
async function handlePromptSubmit(prompt) {
|
||||
handleOfficeCommand(prompt);
|
||||
await runAction(() => sendPrompt(prompt));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="app-shell__header">
|
||||
<div>
|
||||
<div className="app-shell__title">
|
||||
<p className="app-shell__eyebrow">1996 COMMAND CENTER</p>
|
||||
<h1>Retro Claude Team Console</h1>
|
||||
<p className="app-shell__project" title={session.currentProjectPath ?? "None"}>
|
||||
<span>Current Project:</span> {session.currentProjectPath ?? "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="app-shell__meta">
|
||||
<span>LIVE STREAM</span>
|
||||
<span>TEAM COMMS</span>
|
||||
<span>PIXEL MODE</span>
|
||||
<span>LINK: {connected ? "ONLINE" : "OFFLINE"}</span>
|
||||
<span>SESSION: {String(session.status || "idle").toUpperCase()}</span>
|
||||
<span>TEAM: {session.teamActivated ? "ACTIVE" : "STANDBY"}</span>
|
||||
<span>MODEL: {session.runtime?.anthropicModel || "N/A"}</span>
|
||||
<span>KEY: {String(session.runtime?.activeKey || "pro").toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShellFrame>
|
||||
<StatusStrip connected={connected} session={session} />
|
||||
<div className="console-grid">
|
||||
<div className="console-grid__side">
|
||||
<TeamBoard
|
||||
chat={chat}
|
||||
view={teamView}
|
||||
onViewChange={setTeamView}
|
||||
officeAgents={officeAgents}
|
||||
onAgentArrive={handleAgentArrive}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={handleOfficeAgentSelect}
|
||||
onOfficeObjectSelect={handleOfficeObjectSelect}
|
||||
onFloorSelect={handleOfficeFloorSelect}
|
||||
onZoneSelect={handleOfficeZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={handleDismissSpeech}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-grid__main">
|
||||
<ChatStream
|
||||
chat={chat}
|
||||
session={session}
|
||||
headerExtra={
|
||||
<SessionToolbar
|
||||
session={session}
|
||||
busy={busy}
|
||||
onStart={() => runAction(startSession)}
|
||||
onActivate={() => runAction(activateTeam)}
|
||||
onStop={() => runAction(stopSession)}
|
||||
onClearProject={() => runAction(clearProject)}
|
||||
onSelectProject={() => runAction(selectProject)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<div className="console-grid">
|
||||
<div className="console-grid__main">
|
||||
<ChatStream chat={chat} session={session} />
|
||||
<PromptComposer
|
||||
disabled={busy || session.status !== "running"}
|
||||
onSubmit={(prompt) => runAction(() => sendPrompt(prompt))}
|
||||
disabled={busy || session.status !== "running" || !session.teamActivated || !session.currentProjectPath}
|
||||
onSubmit={handlePromptSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-grid__side">
|
||||
<TeamBoard chat={chat} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||
</ShellFrame>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
|
||||
export default function ChatStream({ chat, session }) {
|
||||
export default function ChatStream({ chat, session, headerExtra = null }) {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -16,7 +16,12 @@ export default function ChatStream({ chat, session }) {
|
||||
const isEmpty = !chat.trim();
|
||||
|
||||
return (
|
||||
<PanelFrame title="Claude Live Feed" eyebrow="PRIMARY STREAM" className="chat-panel">
|
||||
<PanelFrame
|
||||
title="Claude Live Feed"
|
||||
eyebrow="PRIMARY STREAM"
|
||||
className="chat-panel"
|
||||
headerExtra={headerExtra}
|
||||
>
|
||||
<div className="chat-stream" ref={scrollerRef}>
|
||||
{isEmpty ? (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function PanelFrame({ title, eyebrow, children, className = "" }) {
|
||||
export default function PanelFrame({ title, eyebrow, children, className = "", headerExtra = null }) {
|
||||
return (
|
||||
<section className={`panel-frame ${className}`}>
|
||||
<div className="panel-frame__header">
|
||||
@@ -6,6 +6,7 @@ export default function PanelFrame({ title, eyebrow, children, className = "" })
|
||||
<p className="panel-frame__eyebrow">{eyebrow}</p>
|
||||
<h2 className="panel-frame__title">{title}</h2>
|
||||
</div>
|
||||
{headerExtra ? <div className="panel-frame__extra">{headerExtra}</div> : null}
|
||||
</div>
|
||||
<div className="panel-frame__body">{children}</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function PixelButton({ tone = "green", disabled, children, ...props }) {
|
||||
export default function PixelButton({ tone = "green", disabled, className = "", children, ...props }) {
|
||||
return (
|
||||
<button className={`pixel-button pixel-button--${tone}`} disabled={disabled} {...props}>
|
||||
<button className={`pixel-button pixel-button--${tone} ${className}`.trim()} disabled={disabled} {...props}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
|
||||
export default function SessionToolbar({ session, busy, onStart, onActivate, onStop }) {
|
||||
const isRunning = session.status === "running";
|
||||
const isStarting = session.status === "starting";
|
||||
|
||||
export default function SessionToolbar({ session, busy, onClearProject, onSelectProject }) {
|
||||
return (
|
||||
<div className="session-toolbar">
|
||||
<PixelButton tone="green" disabled={busy || isRunning || isStarting} onClick={onStart}>
|
||||
Start Session
|
||||
<div className="session-toolbar session-toolbar--inline">
|
||||
<PixelButton tone="red" disabled={busy || !session.currentProjectPath} onClick={onClearProject}>
|
||||
Clean Project
|
||||
</PixelButton>
|
||||
<PixelButton tone="cyan" disabled={busy || !isRunning} onClick={onActivate}>
|
||||
Activate Team
|
||||
</PixelButton>
|
||||
<PixelButton tone="red" disabled={busy || (!isRunning && session.status !== "starting")} onClick={onStop}>
|
||||
Stop Session
|
||||
<PixelButton tone="amber" disabled={busy} onClick={onSelectProject}>
|
||||
Select Project
|
||||
</PixelButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
import { parseTeamFeed } from "../lib/teamFeed.js";
|
||||
|
||||
const OfficeCanvas = lazy(() => import("../office/OfficeCanvas.jsx"));
|
||||
|
||||
function TeamCard({ member }) {
|
||||
return (
|
||||
<article className="team-card">
|
||||
@@ -21,7 +25,7 @@ function TeamCard({ member }) {
|
||||
<span>NO SIGNAL YET</span>
|
||||
</div>
|
||||
) : (
|
||||
member.messages.slice(-4).map((message) => (
|
||||
[...member.messages].slice(-4).reverse().map((message) => (
|
||||
<article key={message.id} className="team-message">
|
||||
<span className="team-message__speaker">{message.speaker}:</span>
|
||||
<pre>{message.text}</pre>
|
||||
@@ -33,16 +37,88 @@ function TeamCard({ member }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamBoard({ chat }) {
|
||||
export default function TeamBoard({
|
||||
chat,
|
||||
view = "board",
|
||||
onViewChange,
|
||||
officeAgents,
|
||||
onAgentArrive,
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
const members = parseTeamFeed(chat);
|
||||
const isOfficeView = view === "office";
|
||||
const speechByAgent = {
|
||||
teamLead: members.find((member) => member.name === "Mazlum")?.messages.at(-1)?.text ?? "",
|
||||
frontend: members.find((member) => member.name === "Berkecan")?.messages.at(-1)?.text ?? "",
|
||||
backend: members.find((member) => member.name === "Simsar")?.messages.at(-1)?.text ?? "",
|
||||
uiux: members.find((member) => member.name === "Aybuke")?.messages.at(-1)?.text ?? "",
|
||||
ios: members.find((member) => member.name === "Ive")?.messages.at(-1)?.text ?? "",
|
||||
trainee: members.find((member) => member.name === "Irgatov")?.messages.at(-1)?.text ?? ""
|
||||
};
|
||||
const speechEntries = Object.fromEntries(
|
||||
Object.entries(speechByAgent).map(([agentId, text]) => [agentId, { text, key: `${agentId}:${text}` }])
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelFrame title="Team Comms Board" eyebrow="ROLE SIGNALS" className="team-board-panel">
|
||||
<PanelFrame
|
||||
title="Team Comms Board"
|
||||
eyebrow="ROLE SIGNALS"
|
||||
className={`team-board-panel team-board-panel--${view}`}
|
||||
headerExtra={
|
||||
<div className="team-board__tabs" role="tablist" aria-label="Team Comms Board view">
|
||||
<PixelButton
|
||||
tone={view === "board" ? "green" : "cyan"}
|
||||
className={`team-board__tab ${view === "board" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange?.("board")}
|
||||
aria-pressed={view === "board"}
|
||||
>
|
||||
Board
|
||||
</PixelButton>
|
||||
<span className="team-board__divider" aria-hidden="true">
|
||||
|
|
||||
</span>
|
||||
<PixelButton
|
||||
tone={view === "office" ? "red" : "cyan"}
|
||||
className={`team-board__tab ${view === "office" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange?.("office")}
|
||||
aria-pressed={view === "office"}
|
||||
>
|
||||
Office
|
||||
</PixelButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isOfficeView ? (
|
||||
<div className="team-board__office" aria-label="Office view">
|
||||
<Suspense fallback={<div className="team-board__office-fallback" />}>
|
||||
<OfficeCanvas
|
||||
debug={false}
|
||||
agents={officeAgents}
|
||||
onAgentArrive={onAgentArrive}
|
||||
speechByAgent={speechEntries}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={onAgentSelect}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onFloorSelect={onFloorSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="team-board">
|
||||
{members.map((member) => (
|
||||
<TeamCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
|
||||
22
web/src/components/ToastStack.jsx
Normal file
22
web/src/components/ToastStack.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function ToastStack({ toasts = [], onDismiss }) {
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => (
|
||||
<button
|
||||
key={toast.id}
|
||||
type="button"
|
||||
className={`toast toast--${toast.tone || "error"}`}
|
||||
onClick={() => onDismiss?.(toast.id)}
|
||||
title="Dismiss notification"
|
||||
>
|
||||
<span className="toast__label">{toast.tone === "error" ? "ALERT" : "NOTICE"}</span>
|
||||
<span className="toast__message">{toast.message}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const initialState = {
|
||||
startedAt: null,
|
||||
teamActivated: false,
|
||||
lastError: null,
|
||||
currentProjectPath: null,
|
||||
runtime: {
|
||||
anthropicModel: "",
|
||||
anthropicBaseUrl: "",
|
||||
@@ -16,13 +17,30 @@ export function useSession(socket) {
|
||||
const [session, setSession] = useState(initialState);
|
||||
const [chat, setChat] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
function pushToast(message, tone = "error") {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
setToasts((current) => [...current, { id, message, tone }].slice(-4));
|
||||
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleState = (value) => setSession(value);
|
||||
const handleChunk = ({ chunk }) => setChat((current) => current + chunk);
|
||||
const handleSnapshot = ({ content }) => setChat(content ?? "");
|
||||
const handleReset = () => setChat("");
|
||||
const handleError = ({ message }) => setError(message);
|
||||
const handleError = ({ message }) => {
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
};
|
||||
|
||||
socket.on("session:state", handleState);
|
||||
socket.on("chat:chunk", handleChunk);
|
||||
@@ -45,6 +63,7 @@ export function useSession(socket) {
|
||||
if (!response?.ok) {
|
||||
const message = response?.error ?? "Unknown socket error";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
@@ -59,11 +78,53 @@ export function useSession(socket) {
|
||||
session,
|
||||
chat,
|
||||
error,
|
||||
toasts,
|
||||
clearError: () => setError(""),
|
||||
dismissToast: (id) => setToasts((current) => current.filter((toast) => toast.id !== id)),
|
||||
startSession: () => emitWithAck("session:start"),
|
||||
stopSession: () => emitWithAck("session:stop"),
|
||||
activateTeam: () => emitWithAck("team:activate"),
|
||||
sendPrompt: (prompt) => emitWithAck("prompt:send", { prompt }),
|
||||
selectProject: async () => {
|
||||
const response = await fetch("/api/project/select", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
const message = payload.error ?? "Project selection failed";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setError("");
|
||||
return payload;
|
||||
},
|
||||
clearProject: async () => {
|
||||
const response = await fetch("/api/project/clear", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
const message = payload.error ?? "Project cleanup failed";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setError("");
|
||||
return payload;
|
||||
},
|
||||
resizeTerminal: (cols, rows) => socket.emit("terminal:resize", { cols, rows })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,8 +69,7 @@ function shouldBreakCurrentEntry(line) {
|
||||
/^[-=]{4,}$/.test(trimmed),
|
||||
/^>/.test(trimmed),
|
||||
/^Kullanici /i.test(trimmed),
|
||||
/^Mazlum nasilsin\?/i.test(trimmed),
|
||||
/^[A-Za-zÀ-ÿ]+,/.test(trimmed)
|
||||
/^Mazlum nasilsin\?/i.test(trimmed)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
@@ -81,6 +80,8 @@ function isContinuationLine(line) {
|
||||
}
|
||||
|
||||
return [
|
||||
/^[•\-]\s+/.test(trimmed),
|
||||
/^[0-9]+\.\s+/.test(trimmed),
|
||||
/^[A-Za-zÀ-ÿ0-9ÇĞİÖŞÜçğıöşü"'`(]/.test(trimmed),
|
||||
/^[.!?…]/.test(trimmed),
|
||||
/^💪|^😊|^🚀|^☕|^🎨|^📱/.test(trimmed)
|
||||
@@ -92,11 +93,34 @@ function dedupeMessages(messages) {
|
||||
const result = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const key = `${message.speaker}::${message.text}`;
|
||||
const normalizedText = String(message.text ?? "").replace(/\s+/g, " ").trim();
|
||||
const firstLine = normalizedText.split("\n")[0]?.trim() ?? "";
|
||||
const key = `${message.speaker}::${normalizedText}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastMessage = result[result.length - 1];
|
||||
if (lastMessage) {
|
||||
const lastNormalized = String(lastMessage.text ?? "").replace(/\s+/g, " ").trim();
|
||||
const lastFirstLine = lastNormalized.split("\n")[0]?.trim() ?? "";
|
||||
|
||||
if (
|
||||
lastMessage.speaker === message.speaker &&
|
||||
firstLine &&
|
||||
lastFirstLine === firstLine
|
||||
) {
|
||||
const mergedText = lastNormalized.length >= normalizedText.length ? lastMessage.text : message.text;
|
||||
result[result.length - 1] = {
|
||||
...lastMessage,
|
||||
text: mergedText
|
||||
};
|
||||
seen.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
result.push(message);
|
||||
}
|
||||
@@ -110,12 +134,14 @@ export function parseTeamFeed(chat) {
|
||||
|
||||
for (const rawLine of String(chat ?? "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
const speakerMatch = line.match(/^[•*\-⏺]?\s*([A-Za-zÀ-ÿ]+):\s*(.*)$/);
|
||||
const speakerMatch = line.match(/^(?:[•*⏺]\s*)?([A-Za-zÀ-ÿ]+):\s*(.*)$/);
|
||||
|
||||
if (speakerMatch) {
|
||||
const member = memberMap.get(normalizeSpeaker(speakerMatch[1]));
|
||||
if (!member) {
|
||||
currentEntry = null;
|
||||
if (currentEntry && isContinuationLine(line)) {
|
||||
currentEntry.text = currentEntry.text ? `${currentEntry.text}\n${line}` : line;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
376
web/src/office/OfficeAgent.jsx
Normal file
376
web/src/office/OfficeAgent.jsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Billboard, Html, RoundedBox } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
function nearlyEqual(a, b, epsilon = 0.04) {
|
||||
return Math.abs(a - b) < epsilon;
|
||||
}
|
||||
|
||||
function positionsMatch(current, target) {
|
||||
return nearlyEqual(current[0], target[0]) && nearlyEqual(current[1], target[1]) && nearlyEqual(current[2], target[2]);
|
||||
}
|
||||
|
||||
function HairStyle({ style, color }) {
|
||||
if (style === "bob") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.77, 0.01]}>
|
||||
<boxGeometry args={[0.46, 0.16, 0.44]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.19, 1.63, -0.01]}>
|
||||
<boxGeometry args={[0.09, 0.24, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.19, 1.63, -0.01]}>
|
||||
<boxGeometry args={[0.09, 0.24, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "swept") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.76, 0.02]}>
|
||||
<boxGeometry args={[0.4, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.09, 1.82, 0.06]} rotation={[0, 0, -0.38]}>
|
||||
<boxGeometry args={[0.22, 0.06, 0.32]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "sidePart") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.76, 0.02]}>
|
||||
<boxGeometry args={[0.42, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, 1.8, 0.06]} rotation={[0, 0, 0.22]}>
|
||||
<boxGeometry args={[0.18, 0.05, 0.28]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "crew") {
|
||||
return (
|
||||
<mesh castShadow position={[0, 1.75, 0.03]}>
|
||||
<boxGeometry args={[0.36, 0.08, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.98} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<mesh castShadow position={[0, 1.76, 0.04]}>
|
||||
<boxGeometry args={[0.4, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.96} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeAgent({
|
||||
agent,
|
||||
onArrive,
|
||||
selected = false,
|
||||
speechText = "",
|
||||
speechKey = "",
|
||||
onSelect,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
const groupRef = useRef(null);
|
||||
const bodyRef = useRef(null);
|
||||
const lastArrivalKeyRef = useRef("");
|
||||
const targetRef = useRef(new Vector3(...agent.targetPosition));
|
||||
const leftArmRef = useRef(null);
|
||||
const rightArmRef = useRef(null);
|
||||
const leftLegRef = useRef(null);
|
||||
const rightLegRef = useRef(null);
|
||||
const walkCycleRef = useRef(0);
|
||||
const appearance = {
|
||||
skinColor: "#f0c6a9",
|
||||
hairColor: "#171717",
|
||||
hairStyle: "short",
|
||||
shirtColor: "#fbfbf4",
|
||||
tieColor: null,
|
||||
lowerColor: "#6d7078",
|
||||
shoeColor: "#111111",
|
||||
lowerType: "pants",
|
||||
...agent.appearance
|
||||
};
|
||||
|
||||
const isSkirt = appearance.lowerType === "skirt";
|
||||
const isMoving = !positionsMatch(agent.currentPosition, agent.targetPosition);
|
||||
const isSeated = !isMoving && String(agent.currentZoneId ?? "").endsWith("Desk");
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupRef.current.position.set(...agent.currentPosition);
|
||||
}, [agent.currentPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current.set(...agent.targetPosition);
|
||||
}, [agent.targetPosition]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!groupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speed = Math.min(1, delta * 1.44);
|
||||
groupRef.current.position.lerp(targetRef.current, speed);
|
||||
|
||||
const directionX = targetRef.current.x - groupRef.current.position.x;
|
||||
const directionZ = targetRef.current.z - groupRef.current.position.z;
|
||||
if (bodyRef.current) {
|
||||
if (isMoving && (Math.abs(directionX) > 0.01 || Math.abs(directionZ) > 0.01)) {
|
||||
const targetYaw = Math.atan2(directionX, directionZ);
|
||||
bodyRef.current.rotation.y += (targetYaw - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
|
||||
} else if (isSeated) {
|
||||
bodyRef.current.rotation.y += (0 - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMoving) {
|
||||
walkCycleRef.current += delta * 6;
|
||||
const swing = Math.sin(walkCycleRef.current) * 0.45;
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = swing;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = -swing;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = -swing;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = swing;
|
||||
}
|
||||
} else if (isSeated) {
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = -0.18;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = -0.18;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = Math.PI / 2.7;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = Math.PI / 2.7;
|
||||
}
|
||||
} else {
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = 0;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = 0;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = 0;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const current = [
|
||||
groupRef.current.position.x,
|
||||
groupRef.current.position.y,
|
||||
groupRef.current.position.z
|
||||
];
|
||||
|
||||
const arrivalKey = `${agent.id}:${agent.targetPosition.join(",")}`;
|
||||
if (positionsMatch(current, agent.targetPosition) && lastArrivalKeyRef.current !== arrivalKey) {
|
||||
lastArrivalKeyRef.current = arrivalKey;
|
||||
onArrive?.(agent.id, agent.targetPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.(agent.id);
|
||||
}}
|
||||
>
|
||||
{selected ? (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
|
||||
<ringGeometry args={[0.36, 0.48, 32]} />
|
||||
<meshBasicMaterial color="#7ff7ff" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<group ref={bodyRef}>
|
||||
<HairStyle style={appearance.hairStyle} color={appearance.hairColor} />
|
||||
<mesh castShadow position={[0, isSeated ? 1.47 : 1.55, 0]}>
|
||||
<boxGeometry args={[0.42, 0.42, 0.42]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, isSeated ? 1.5 : 1.58, 0.222]}>
|
||||
<boxGeometry args={[0.08, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.08, isSeated ? 1.5 : 1.58, 0.222]}>
|
||||
<boxGeometry args={[0.08, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, isSeated ? 1.57 : 1.65, 0.214]}>
|
||||
<boxGeometry args={[0.1, 0.018, 0.02]} />
|
||||
<meshStandardMaterial color="#222222" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.08, isSeated ? 1.57 : 1.65, 0.214]}>
|
||||
<boxGeometry args={[0.1, 0.018, 0.02]} />
|
||||
<meshStandardMaterial color="#222222" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, isSeated ? 1.42 : 1.5, 0.22]} rotation={[Math.PI / 2.4, 0, 0]}>
|
||||
<coneGeometry args={[0.035, 0.13, 4]} />
|
||||
<meshStandardMaterial color="#deaf90" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, isSeated ? 1.31 : 1.39, 0.222]}>
|
||||
<boxGeometry args={[0.12, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.5} />
|
||||
</mesh>
|
||||
<RoundedBox castShadow receiveShadow position={[0, isSeated ? 1.02 : 1.13, 0]} args={[0.62, 0.58, 0.32]} radius={0.04} smoothness={2}>
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</RoundedBox>
|
||||
{appearance.tieColor ? (
|
||||
<mesh castShadow position={[0, isSeated ? 1.01 : 1.12, 0.17]}>
|
||||
<boxGeometry args={[0.11, 0.48, 0.03]} />
|
||||
<meshStandardMaterial color={appearance.tieColor} roughness={0.64} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<group ref={leftArmRef} position={[-0.44, isSeated ? 1.12 : 1.24, 0]}>
|
||||
<mesh castShadow position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.22, 0.16, 0.18]} />
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.31, 0]}>
|
||||
<boxGeometry args={[0.14, 0.28, 0.14]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightArmRef} position={[0.44, isSeated ? 1.12 : 1.24, 0]}>
|
||||
<mesh castShadow position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.22, 0.16, 0.18]} />
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.31, 0]}>
|
||||
<boxGeometry args={[0.14, 0.28, 0.14]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
|
||||
</mesh>
|
||||
</group>
|
||||
{isSkirt ? (
|
||||
<mesh castShadow position={[0, isSeated ? 0.7 : 0.76, 0]}>
|
||||
<boxGeometry args={[0.54, 0.22, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
|
||||
</mesh>
|
||||
) : (
|
||||
<mesh castShadow position={[0, isSeated ? 0.72 : 0.76, 0]}>
|
||||
<boxGeometry args={[0.5, 0.22, 0.3]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
|
||||
</mesh>
|
||||
)}
|
||||
{isSeated ? (
|
||||
<>
|
||||
<group ref={leftLegRef} position={[-0.14, 0.73, 0.03]}>
|
||||
<mesh castShadow position={[0, -0.07, 0.11]}>
|
||||
<boxGeometry args={[0.14, 0.14, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.39, 0.24]}>
|
||||
<boxGeometry args={[0.14, 0.42, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.65, 0.32]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightLegRef} position={[0.14, 0.73, 0.03]}>
|
||||
<mesh castShadow position={[0, -0.07, 0.11]}>
|
||||
<boxGeometry args={[0.14, 0.14, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.39, 0.24]}>
|
||||
<boxGeometry args={[0.14, 0.42, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.65, 0.32]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<group ref={leftLegRef} position={[-0.14, 0.66, 0]}>
|
||||
<mesh castShadow position={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.48, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.04, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, 0.16]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.55, 0.02]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightLegRef} position={[0.14, 0.66, 0]}>
|
||||
<mesh castShadow position={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.48, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.04, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, 0.16]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.55, 0.02]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
<Html position={[0, 2.02, 0]} center distanceFactor={10}>
|
||||
<div className="office-agent__label">{agent.name}</div>
|
||||
</Html>
|
||||
</group>
|
||||
{speechText ? (
|
||||
<Billboard position={[0, 3.03, 0]} follow lockX={false} lockY={false} lockZ={false}>
|
||||
<Html center distanceFactor={10}>
|
||||
<button
|
||||
type="button"
|
||||
className="office-agent__bubble"
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.scrollTop += event.deltaY;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDismissSpeech?.(agent.id, speechKey);
|
||||
}}
|
||||
>
|
||||
{speechText}
|
||||
</button>
|
||||
</Html>
|
||||
</Billboard>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeCamera.jsx
Normal file
16
web/src/office/OfficeCamera.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
|
||||
export default function OfficeCamera() {
|
||||
return (
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
enableDamping
|
||||
dampingFactor={0.08}
|
||||
minDistance={12}
|
||||
maxDistance={30}
|
||||
minPolarAngle={0.7}
|
||||
maxPolarAngle={1.02}
|
||||
target={[0.5, 0.8, 0.2]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
web/src/office/OfficeCanvas.jsx
Normal file
36
web/src/office/OfficeCanvas.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import OfficeScene from "./OfficeScene.jsx";
|
||||
|
||||
export default function OfficeCanvas({
|
||||
debug = false,
|
||||
agents = {},
|
||||
onAgentArrive,
|
||||
speechByAgent = {},
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
return (
|
||||
<div className="office-canvas">
|
||||
<Canvas shadows dpr={[1, 1.5]} camera={{ position: [12, 12, 12], fov: 38 }}>
|
||||
<OfficeScene
|
||||
debug={debug}
|
||||
agents={agents}
|
||||
onAgentArrive={onAgentArrive}
|
||||
speechByAgent={speechByAgent}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={onAgentSelect}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onFloorSelect={onFloorSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeDebugLabels.jsx
Normal file
16
web/src/office/OfficeDebugLabels.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
|
||||
export default function OfficeDebugLabels({ zones = [] }) {
|
||||
return (
|
||||
<group>
|
||||
{zones.map((zone) => (
|
||||
<Html key={zone.id} position={[zone.position[0], 1.8, zone.position[2]]} center transform sprite>
|
||||
<div className="office-debug-label">
|
||||
<span>{zone.label}</span>
|
||||
<code>{zone.id}</code>
|
||||
</div>
|
||||
</Html>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeFloor.jsx
Normal file
16
web/src/office/OfficeFloor.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function OfficeFloor({ onSelect }) {
|
||||
return (
|
||||
<mesh
|
||||
receiveShadow
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, -0.001, 0]}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.(event.point.toArray());
|
||||
}}
|
||||
>
|
||||
<planeGeometry args={[24, 18]} />
|
||||
<meshStandardMaterial color="#efe0c5" roughness={0.95} metalness={0.02} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
86
web/src/office/OfficeLayout.jsx
Normal file
86
web/src/office/OfficeLayout.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { TextureLoader } from "three";
|
||||
import Desk from "./primitives/Desk.jsx";
|
||||
import MeetingTable from "./primitives/MeetingTable.jsx";
|
||||
import CoffeeMachine from "./primitives/CoffeeMachine.jsx";
|
||||
import { OFFICE_ZONES } from "./officeZones.js";
|
||||
|
||||
function AccentWallPanels() {
|
||||
const steveTexture = useLoader(TextureLoader, "/steve.png");
|
||||
const monaTexture = useLoader(TextureLoader, "/mona.png");
|
||||
const ataTexture = useLoader(TextureLoader, "/ata-cropped.png");
|
||||
const panels = [
|
||||
{ position: [-8.2, 1.8, -8.78], color: "#495b8a" },
|
||||
{ position: [0, 2.15, -8.76], color: "#6b4c78" },
|
||||
{ position: [7.4, 1.8, -8.78], color: "#495b8a" }
|
||||
];
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-6.2, 1.47, -8.73]}>
|
||||
<planeGeometry args={[1.456, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={monaTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 1.47, -8.73]}>
|
||||
<planeGeometry args={[3.24, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={steveTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[6.2, 1.47, -8.73]}>
|
||||
<planeGeometry args={[1.62, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={ataTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorDecals() {
|
||||
return (
|
||||
<group>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[5.8, 0.002, 4]}>
|
||||
<circleGeometry args={[1.15, 24]} />
|
||||
<meshStandardMaterial color="#d9ccb6" roughness={1} />
|
||||
</mesh>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[6.4, 0.002, -4]}>
|
||||
<circleGeometry args={[2.15, 28]} />
|
||||
<meshStandardMaterial color="#ded0ba" roughness={1} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeLayout({ selectedOfficeObject, onOfficeObjectSelect, onZoneSelect }) {
|
||||
const coffeeMachineSelected = selectedOfficeObject?.type === "object" && selectedOfficeObject?.id === "coffeeMachine";
|
||||
|
||||
return (
|
||||
<group>
|
||||
<FloorDecals />
|
||||
<AccentWallPanels />
|
||||
<Desk position={OFFICE_ZONES[0].position} nameplateColor="#d6c15d" onSelect={() => onZoneSelect?.("teamLeadDesk")} />
|
||||
<Desk position={OFFICE_ZONES[1].position} nameplateColor="#49c2f1" onSelect={() => onZoneSelect?.("frontendDesk")} />
|
||||
<Desk position={OFFICE_ZONES[2].position} nameplateColor="#7bd87a" onSelect={() => onZoneSelect?.("backendDesk")} />
|
||||
<Desk position={OFFICE_ZONES[3].position} nameplateColor="#f48cc7" onSelect={() => onZoneSelect?.("uiuxDesk")} />
|
||||
<Desk position={OFFICE_ZONES[4].position} nameplateColor="#8aa4ff" onSelect={() => onZoneSelect?.("iosDesk")} />
|
||||
<MeetingTable position={OFFICE_ZONES[5].position} />
|
||||
<CoffeeMachine
|
||||
position={OFFICE_ZONES[6].position}
|
||||
selected={coffeeMachineSelected}
|
||||
onSelect={() => onOfficeObjectSelect?.("object", "coffeeMachine")}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
21
web/src/office/OfficeLighting.jsx
Normal file
21
web/src/office/OfficeLighting.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function OfficeLighting() {
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={2.1} />
|
||||
<hemisphereLight args={["#fff8ea", "#2a2018", 1.45]} />
|
||||
<directionalLight
|
||||
castShadow
|
||||
intensity={2.45}
|
||||
position={[7, 12, 5]}
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
shadow-camera-near={0.5}
|
||||
shadow-camera-far={40}
|
||||
shadow-camera-left={-16}
|
||||
shadow-camera-right={16}
|
||||
shadow-camera-top={16}
|
||||
shadow-camera-bottom={-16}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
web/src/office/OfficeScene.jsx
Normal file
51
web/src/office/OfficeScene.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import OfficeCamera from "./OfficeCamera.jsx";
|
||||
import OfficeLighting from "./OfficeLighting.jsx";
|
||||
import OfficeFloor from "./OfficeFloor.jsx";
|
||||
import OfficeWalls from "./OfficeWalls.jsx";
|
||||
import OfficeLayout from "./OfficeLayout.jsx";
|
||||
import OfficeDebugLabels from "./OfficeDebugLabels.jsx";
|
||||
import OfficeAgent from "./OfficeAgent.jsx";
|
||||
import { OFFICE_ZONES } from "./officeZones.js";
|
||||
|
||||
export default function OfficeScene({
|
||||
debug = false,
|
||||
agents = {},
|
||||
onAgentArrive,
|
||||
speechByAgent = {},
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech = {},
|
||||
onDismissSpeech
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={["#1a130f"]} />
|
||||
<fog attach="fog" args={["#1a130f", 42, 72]} />
|
||||
<OfficeLighting />
|
||||
<OfficeFloor onSelect={onFloorSelect} />
|
||||
<OfficeWalls />
|
||||
<OfficeLayout
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
/>
|
||||
{Object.values(agents).map((agent) => (
|
||||
<OfficeAgent
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onArrive={onAgentArrive}
|
||||
selected={selectedOfficeObject?.type === "agent" && selectedOfficeObject?.id === agent.id}
|
||||
speechText={dismissedSpeech[agent.id] === speechByAgent[agent.id]?.key ? "" : (speechByAgent[agent.id]?.text ?? "")}
|
||||
speechKey={speechByAgent[agent.id]?.key ?? ""}
|
||||
onSelect={onAgentSelect}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
))}
|
||||
<OfficeCamera />
|
||||
{debug ? <OfficeDebugLabels zones={OFFICE_ZONES} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
web/src/office/OfficeWalls.jsx
Normal file
19
web/src/office/OfficeWalls.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
function Wall({ position, args }) {
|
||||
return (
|
||||
<mesh castShadow receiveShadow position={position}>
|
||||
<boxGeometry args={args} />
|
||||
<meshStandardMaterial color="#8f8b93" roughness={0.92} metalness={0.04} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeWalls() {
|
||||
return (
|
||||
<group>
|
||||
<Wall position={[0, 1.3, -9]} args={[24, 2.6, 0.35]} />
|
||||
<Wall position={[-12, 1.3, 0]} args={[0.35, 2.6, 18]} />
|
||||
<Wall position={[12, 1.3, 0]} args={[0.35, 2.6, 18]} />
|
||||
<Wall position={[0, 1.3, 9]} args={[24, 2.6, 0.35]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
114
web/src/office/officeAgents.js
Normal file
114
web/src/office/officeAgents.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { getZoneById } from "./officeZones.js";
|
||||
|
||||
function createAgent({
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
zoneId,
|
||||
color,
|
||||
appearance
|
||||
}) {
|
||||
const zone = getZoneById(zoneId);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
color,
|
||||
currentZoneId: zoneId,
|
||||
targetZoneId: zoneId,
|
||||
currentPosition: zone?.approachPosition ?? [0, 0, 0],
|
||||
targetPosition: zone?.approachPosition ?? [0, 0, 0],
|
||||
appearance
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialOfficeAgents() {
|
||||
return {
|
||||
teamLead: createAgent({
|
||||
id: "teamLead",
|
||||
name: "Mazlum",
|
||||
role: "Team Lead",
|
||||
zoneId: "teamLeadDesk",
|
||||
color: "#e0c15c",
|
||||
appearance: {
|
||||
skinColor: "#f0c6a9",
|
||||
hairColor: "#171717",
|
||||
hairStyle: "short",
|
||||
shirtColor: "#fbfbf4",
|
||||
tieColor: "#ba1d2f",
|
||||
lowerColor: "#75777d",
|
||||
shoeColor: "#111111",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
frontend: createAgent({
|
||||
id: "frontend",
|
||||
name: "Berkecan",
|
||||
role: "Frontend Developer",
|
||||
zoneId: "frontendDesk",
|
||||
color: "#49c2f1",
|
||||
appearance: {
|
||||
skinColor: "#e8c09b",
|
||||
hairColor: "#3e2415",
|
||||
hairStyle: "swept",
|
||||
shirtColor: "#3d79d8",
|
||||
tieColor: null,
|
||||
lowerColor: "#d7c8a4",
|
||||
shoeColor: "#3b2d25",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
backend: createAgent({
|
||||
id: "backend",
|
||||
name: "Simsar",
|
||||
role: "Backend Developer",
|
||||
zoneId: "backendDesk",
|
||||
color: "#7bd87a",
|
||||
appearance: {
|
||||
skinColor: "#dcb28f",
|
||||
hairColor: "#2a2a2a",
|
||||
hairStyle: "crew",
|
||||
shirtColor: "#2f6b48",
|
||||
tieColor: null,
|
||||
lowerColor: "#42464f",
|
||||
shoeColor: "#141414",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
uiux: createAgent({
|
||||
id: "uiux",
|
||||
name: "Aybuke",
|
||||
role: "UI/UX Designer",
|
||||
zoneId: "uiuxDesk",
|
||||
color: "#f48cc7",
|
||||
appearance: {
|
||||
skinColor: "#efc4aa",
|
||||
hairColor: "#6b3f30",
|
||||
hairStyle: "bob",
|
||||
shirtColor: "#d8a0cf",
|
||||
tieColor: null,
|
||||
lowerColor: "#564760",
|
||||
shoeColor: "#2f2430",
|
||||
lowerType: "skirt"
|
||||
}
|
||||
}),
|
||||
ios: createAgent({
|
||||
id: "ios",
|
||||
name: "Ive",
|
||||
role: "iOS Developer",
|
||||
zoneId: "iosDesk",
|
||||
color: "#8aa4ff",
|
||||
appearance: {
|
||||
skinColor: "#e6bc97",
|
||||
hairColor: "#7b4d22",
|
||||
hairStyle: "sidePart",
|
||||
shirtColor: "#d4b258",
|
||||
tieColor: null,
|
||||
lowerColor: "#5a77ae",
|
||||
shoeColor: "#171717",
|
||||
lowerType: "pants"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
62
web/src/office/officeCommands.js
Normal file
62
web/src/office/officeCommands.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const AGENT_ALIASES = {
|
||||
teamLead: ["team lead", "lead", "mazlum", "mazlum bey"],
|
||||
frontend: ["frontend", "frontend developer", "berkecan"],
|
||||
backend: ["backend", "backend developer", "simsar"],
|
||||
uiux: ["ui ux", "ui/ux", "designer", "ui ux designer", "aybuke", "aybuke hanim", "aybüke", "aybüke hanım"],
|
||||
ios: ["ios", "ios developer", "ive", "i os"]
|
||||
};
|
||||
|
||||
const ZONE_ALIASES = {
|
||||
teamLeadDesk: ["team lead desk", "lead desk", "mazlum desk", "team lead masasi", "mazlum masasi"],
|
||||
frontendDesk: ["frontend desk", "frontend masasi", "berkecan masasi"],
|
||||
backendDesk: ["backend desk", "backend masasi", "simsar masasi"],
|
||||
uiuxDesk: ["ui ux desk", "ui/ux desk", "designer desk", "aybuke masasi", "aybüke masası", "ui ux masasi"],
|
||||
iosDesk: ["ios desk", "ios masasi", "ive masasi", "i os masasi"],
|
||||
meetingTable: ["meeting table", "toplanti masasi", "toplanti masasi", "meeting room", "toplanti"],
|
||||
coffeeMachine: ["coffee machine", "kahve makinesi", "kahve alani", "kahve makinasi"]
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findMatch(normalizedText, map) {
|
||||
return Object.entries(map).find(([, aliases]) => aliases.some((alias) => normalizedText.includes(alias)))?.[0] ?? null;
|
||||
}
|
||||
|
||||
function inferZoneFromIntent(normalizedText) {
|
||||
if (/\b(kahve al|kahve alsin|kahve alsın|kahve getir|kahve makinesi|kahve makinasi|kahve alanina|kahve alanına)\b/.test(normalizedText)) {
|
||||
return "coffeeMachine";
|
||||
}
|
||||
|
||||
if (/\b(toplantiya git|toplanti masasi|toplantiya|toplanti alanina|toplanti alanina|masaya gec|masaya geç)\b/.test(normalizedText)) {
|
||||
return "meetingTable";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOfficeCommand(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
|
||||
if (!/\b(git|gidebilir|gitsin|gidin|toplan|yuru|yurusun|gitmesi|al|alsin|alsın|getir)\b/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentId = findMatch(normalized, AGENT_ALIASES);
|
||||
const zoneId = findMatch(normalized, ZONE_ALIASES) ?? inferZoneFromIntent(normalized);
|
||||
|
||||
if (!agentId || !zoneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "move",
|
||||
agentId,
|
||||
zoneId
|
||||
};
|
||||
}
|
||||
13
web/src/office/officeSceneData.js
Normal file
13
web/src/office/officeSceneData.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const officeBadges = [
|
||||
{ id: "lead", label: "Mazlum Bey", tone: "amber" },
|
||||
{ id: "frontend", label: "Frontend Kanka", tone: "cyan" },
|
||||
{ id: "backend", label: "Backend Kanka", tone: "green" },
|
||||
{ id: "ios", label: "iOS Kanka", tone: "cyan" },
|
||||
{ id: "ui", label: "UI Hanim", tone: "amber" }
|
||||
];
|
||||
|
||||
export const officeNotes = [
|
||||
"Patron icin hizli briefing hazir.",
|
||||
"3D sahne: masa, ekranlar, duvar notlari, neon akis.",
|
||||
"Office modu sadece gorunum degistirir; veri akisi ayni kalir."
|
||||
];
|
||||
8
web/src/office/officeStations.js
Normal file
8
web/src/office/officeStations.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const OFFICE_STATIONS = [
|
||||
{ id: "mazlum", name: "Mazlum Bey", role: "Team Lead", debug: "BRIEFING NODE", x: 548, y: 196, tone: "amber" },
|
||||
{ id: "berkecan", name: "Berkecan", role: "Frontend", debug: "UI BENCH", x: 264, y: 392, tone: "cyan" },
|
||||
{ id: "simsar", name: "Simsar", role: "Backend", debug: "API BENCH", x: 820, y: 388, tone: "green" },
|
||||
{ id: "aybuke", name: "UI Hanim", role: "Design Wall", debug: "UX BOARD", x: 250, y: 190, tone: "amber" },
|
||||
{ id: "ive", name: "Ive", role: "iOS Bay", debug: "MOBILE RACK", x: 842, y: 178, tone: "cyan" },
|
||||
{ id: "irgatov", name: "Irgatov", role: "Coffee Station", debug: "SERVICE DESK", x: 548, y: 562, tone: "red" }
|
||||
];
|
||||
62
web/src/office/officeZones.js
Normal file
62
web/src/office/officeZones.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export const OFFICE_ZONES = [
|
||||
{
|
||||
id: "teamLeadDesk",
|
||||
label: "Team Lead Desk",
|
||||
role: "Team Lead",
|
||||
type: "desk",
|
||||
position: [0, 0, -4.2],
|
||||
approachPosition: [0, 0, -5.25]
|
||||
},
|
||||
{
|
||||
id: "frontendDesk",
|
||||
label: "Frontend Desk",
|
||||
role: "Frontend Dev",
|
||||
type: "desk",
|
||||
position: [-3.8, 0, -0.9],
|
||||
approachPosition: [-3.8, 0, -1.95]
|
||||
},
|
||||
{
|
||||
id: "backendDesk",
|
||||
label: "Backend Desk",
|
||||
role: "Backend Dev",
|
||||
type: "desk",
|
||||
position: [3.8, 0, -0.9],
|
||||
approachPosition: [3.8, 0, -1.95]
|
||||
},
|
||||
{
|
||||
id: "uiuxDesk",
|
||||
label: "UI/UX Desk",
|
||||
role: "UI/UX Designer",
|
||||
type: "desk",
|
||||
position: [-3.8, 0, 2.9],
|
||||
approachPosition: [-3.8, 0, 1.85]
|
||||
},
|
||||
{
|
||||
id: "iosDesk",
|
||||
label: "iOS Desk",
|
||||
role: "iOS Dev",
|
||||
type: "desk",
|
||||
position: [3.8, 0, 2.9],
|
||||
approachPosition: [3.8, 0, 1.85]
|
||||
},
|
||||
{
|
||||
id: "meetingTable",
|
||||
label: "Meeting Table",
|
||||
role: "Meeting",
|
||||
type: "meeting",
|
||||
position: [6.4, 0, -4],
|
||||
approachPosition: [5, 0, -3.9]
|
||||
},
|
||||
{
|
||||
id: "coffeeMachine",
|
||||
label: "Coffee Machine",
|
||||
role: "Utility",
|
||||
type: "utility",
|
||||
position: [6.8, 0, 4],
|
||||
approachPosition: [5.7, 0, 4]
|
||||
}
|
||||
];
|
||||
|
||||
export function getZoneById(id) {
|
||||
return OFFICE_ZONES.find((zone) => zone.id === id) ?? null;
|
||||
}
|
||||
22
web/src/office/primitives/Chair.jsx
Normal file
22
web/src/office/primitives/Chair.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function Chair({ position = [0, 0, 0], rotation = [0, 0, 0] }) {
|
||||
return (
|
||||
<group position={position} rotation={rotation}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.42, 0]}>
|
||||
<boxGeometry args={[0.7, 0.12, 0.7]} />
|
||||
<meshStandardMaterial color="#6e719c" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.9, -0.24]}>
|
||||
<boxGeometry args={[0.7, 0.84, 0.12]} />
|
||||
<meshStandardMaterial color="#6e719c" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.2, 0]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.42, 10]} />
|
||||
<meshStandardMaterial color="#3d4259" roughness={0.8} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.04, 0]}>
|
||||
<cylinderGeometry args={[0.32, 0.22, 0.08, 12]} />
|
||||
<meshStandardMaterial color="#32364a" roughness={0.8} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
166
web/src/office/primitives/CoffeeMachine.jsx
Normal file
166
web/src/office/primitives/CoffeeMachine.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
|
||||
function Cup({ position = [0, 0, 0], scale = 1, sleeve = false }) {
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh castShadow position={[0, 0.18, 0]}>
|
||||
<cylinderGeometry args={[0.09, 0.12, 0.28, 12]} />
|
||||
<meshStandardMaterial color="#f8f8f4" roughness={0.92} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 0.335, 0]}>
|
||||
<cylinderGeometry args={[0.11, 0.11, 0.05, 12]} />
|
||||
<meshStandardMaterial color="#f4f4ef" roughness={0.9} />
|
||||
</mesh>
|
||||
{sleeve ? (
|
||||
<mesh castShadow position={[0, 0.18, 0.095]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<boxGeometry args={[0.15, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#8d6239" roughness={0.95} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<mesh castShadow position={[0, 0.18, 0.105]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.03, 0.03, sleeve ? 0.08 : 0.1, 18]} />
|
||||
<meshStandardMaterial color="#1f8b56" roughness={0.82} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function TopCups() {
|
||||
const topCupPositions = [
|
||||
[-0.38, 1.55, -0.14],
|
||||
[-0.16, 1.55, -0.14],
|
||||
[0.06, 1.55, -0.14],
|
||||
[0.28, 1.55, -0.14],
|
||||
[-0.27, 1.55, 0.06],
|
||||
[-0.05, 1.55, 0.06],
|
||||
[0.17, 1.55, 0.06]
|
||||
];
|
||||
|
||||
return topCupPositions.map((position, index) => (
|
||||
<Cup key={index} position={position} scale={0.75} />
|
||||
));
|
||||
}
|
||||
|
||||
function FrontCups() {
|
||||
return (
|
||||
<group>
|
||||
<Cup position={[-0.28, 0.42, 0.85]} scale={1.15} />
|
||||
<Cup position={[-0.06, 0.42, 0.82]} scale={1.05} />
|
||||
<Cup position={[0.24, 0.42, 0.83]} scale={0.82} sleeve />
|
||||
<mesh castShadow position={[0.55, 0.44, 0.82]}>
|
||||
<boxGeometry args={[0.17, 0.12, 0.17]} />
|
||||
<meshStandardMaterial color="#9a6d41" roughness={0.94} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.76, 0.44, 0.78]} rotation={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.04, 0.16]} />
|
||||
<meshStandardMaterial color="#b18458" roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoffeeMachine({ position = [0, 0, 0], selected = false, onSelect }) {
|
||||
return (
|
||||
<group position={position} scale={0.82}>
|
||||
<mesh
|
||||
position={[0, 0.7, 0.12]}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[2.3, 1.7, 1.8]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
{selected ? (
|
||||
<>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.015, 0.12]}>
|
||||
<ringGeometry args={[1.15, 1.35, 40]} />
|
||||
<meshBasicMaterial color="#8fe7ff" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<Html position={[0, 2.15, 0.12]} center sprite>
|
||||
<div className="office-object__label">Kahve Makinesi</div>
|
||||
</Html>
|
||||
</>
|
||||
) : null}
|
||||
<mesh receiveShadow position={[0, 0.22, 0.12]}>
|
||||
<boxGeometry args={[2.25, 0.36, 1.65]} />
|
||||
<meshStandardMaterial color="#6e4024" roughness={0.98} />
|
||||
</mesh>
|
||||
<mesh receiveShadow position={[0, 0.42, 0.12]}>
|
||||
<boxGeometry args={[2.05, 0.08, 1.45]} />
|
||||
<meshStandardMaterial color="#4b2918" roughness={0.95} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0, 1.02, 0]}>
|
||||
<boxGeometry args={[1.55, 0.92, 0.95]} />
|
||||
<meshStandardMaterial color="#20232a" metalness={0.08} roughness={0.62} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.12, 0.18]}>
|
||||
<boxGeometry args={[1.42, 0.78, 0.52]} />
|
||||
<meshStandardMaterial color="#a7adb5" metalness={0.58} roughness={0.28} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.58, -0.04]}>
|
||||
<boxGeometry args={[1.38, 0.08, 0.82]} />
|
||||
<meshStandardMaterial color="#42464d" metalness={0.35} roughness={0.42} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.61, -0.04]}>
|
||||
<boxGeometry args={[1.18, 0.025, 0.62]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.85} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[0, 1.22, 0.45]}>
|
||||
<boxGeometry args={[0.98, 0.18, 0.06]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.26, 1.22, 0.49]}>
|
||||
<boxGeometry args={[0.26, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#5cf0b1" emissive="#29b787" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.26, 1.22, 0.49]}>
|
||||
<boxGeometry args={[0.26, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#66d5ff" emissive="#3d9fd0" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 1.22, 0.49]}>
|
||||
<cylinderGeometry args={[0.09, 0.09, 0.03, 18]} />
|
||||
<meshStandardMaterial color="#d8ddd8" metalness={0.35} roughness={0.25} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.6, 1.24, 0.46]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.6, 1.24, 0.46]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.55} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0, 0.62, 0.26]}>
|
||||
<boxGeometry args={[1.15, 0.08, 0.44]} />
|
||||
<meshStandardMaterial color="#959aa1" metalness={0.58} roughness={0.25} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.67, 0.26]}>
|
||||
<boxGeometry args={[0.96, 0.02, 0.3]} />
|
||||
<meshStandardMaterial color="#4e5358" roughness={0.76} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.24, 0.94, 0.22]}>
|
||||
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
|
||||
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.24, 0.94, 0.22]}>
|
||||
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
|
||||
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.24, 0.78, 0.36]}>
|
||||
<boxGeometry args={[0.24, 0.05, 0.08]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.24, 0.78, 0.36]}>
|
||||
<boxGeometry args={[0.24, 0.05, 0.08]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.6} />
|
||||
</mesh>
|
||||
|
||||
<TopCups />
|
||||
<FrontCups />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
67
web/src/office/primitives/Desk.jsx
Normal file
67
web/src/office/primitives/Desk.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { TextureLoader } from "three";
|
||||
import { DoubleSide } from "three";
|
||||
import Chair from "./Chair.jsx";
|
||||
|
||||
export default function Desk({ position = [0, 0, 0], nameplateColor = "#52b6ff", onSelect }) {
|
||||
const appleLogoTexture = useLoader(TextureLoader, "/apple-logo.png");
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<mesh position={[0, 0.72, -0.08]}>
|
||||
<boxGeometry args={[2.1, 1.5, 2.1]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.78, 0]}>
|
||||
<boxGeometry args={[1.9, 0.16, 1.15]} />
|
||||
<meshStandardMaterial color="#b78256" roughness={0.86} />
|
||||
</mesh>
|
||||
{[
|
||||
[-0.78, 0.38, -0.43],
|
||||
[0.78, 0.38, -0.43],
|
||||
[-0.78, 0.38, 0.43],
|
||||
[0.78, 0.38, 0.43]
|
||||
].map((leg, index) => (
|
||||
<mesh key={index} castShadow receiveShadow position={leg}>
|
||||
<boxGeometry args={[0.12, 0.76, 0.12]} />
|
||||
<meshStandardMaterial color="#8e603b" roughness={0.9} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<group position={[0, 0.92, 0]} rotation={[0, Math.PI, 0]}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.02, 0.02]}>
|
||||
<boxGeometry args={[0.64, 0.04, 0.42]} />
|
||||
<meshStandardMaterial color="#c3c9d2" roughness={0.36} metalness={0.52} />
|
||||
</mesh>
|
||||
<group position={[0, 0.055, -0.19]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh castShadow receiveShadow position={[0, 0, 0.2]}>
|
||||
<boxGeometry args={[0.62, 0.03, 0.4]} />
|
||||
<meshStandardMaterial color="#c8ced6" roughness={0.28} metalness={0.58} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.022, 0.2]} rotation={[-Math.PI / 2, 0, Math.PI]}>
|
||||
<planeGeometry args={[0.18, 0.18]} />
|
||||
<meshBasicMaterial
|
||||
map={appleLogoTexture}
|
||||
transparent
|
||||
alphaTest={0.1}
|
||||
side={DoubleSide}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0.65, 0.9, 0.18]}>
|
||||
<boxGeometry args={[0.26, 0.1, 0.26]} />
|
||||
<meshStandardMaterial color={nameplateColor} roughness={0.55} />
|
||||
</mesh>
|
||||
<Chair position={[0, 0, -1.05]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
14
web/src/office/primitives/MeetingTable.jsx
Normal file
14
web/src/office/primitives/MeetingTable.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MeetingTable({ position = [0, 0, 0] }) {
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.8, 0]}>
|
||||
<cylinderGeometry args={[1.55, 1.55, 0.18, 28]} />
|
||||
<meshStandardMaterial color="#bf8d61" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.36, 0]}>
|
||||
<cylinderGeometry args={[0.22, 0.28, 0.72, 16]} />
|
||||
<meshStandardMaterial color="#8e603b" roughness={0.86} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user