File size: 9,092 Bytes
ff81742
 
 
27a5a57
ff81742
bdec212
a3ef398
4ad9b66
cc21a82
 
323a2a7
bdec212
cc21a82
ff81742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdec212
 
 
 
 
cc21a82
bdec212
 
 
 
 
 
 
6f82055
bdec212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c990d50
bdec212
 
 
 
 
 
cfee1d1
bdec212
 
 
 
 
cfee1d1
 
bdec212
 
cfee1d1
bdec212
 
 
 
 
 
8f94a86
bdec212
 
 
 
 
 
 
 
cfee1d1
bdec212
 
cfee1d1
bdec212
 
cfee1d1
6f82055
cfee1d1
bdec212
cfee1d1
 
bdec212
 
a3ef398
8f94a86
c990d50
bdec212
 
 
 
 
 
 
8f94a86
 
bdec212
 
 
 
 
8f94a86
bdec212
094fd2d
9910b51
bdec212
 
cfee1d1
bdec212
90131db
cfee1d1
90131db
8f94a86
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
// src/App.tsx (نسخه یکپارچه و نهایی)

import React, { useEffect, useRef, useState, FC } from "react";
import './App.scss';
// AppContext را از مسیر درست ایمپورت می‌کنیم
import { AppProvider, useAppContext, PersonalityType, PersonalityInstructions } from "./contexts/AppContext";
import ControlTray from "./components/control-tray/ControlTray";
import { IOSModal } from "./components/ios-modal/IOSModal";
import { isIOS } from "./lib/platform";
import cn from "classnames";
import Logo from "./components/logo/Logo";
import { LiveConfig } from "./multimodal-live-types";

// ===================================================================
// START: کامپوننت مودال مستقیماً اینجا تعریف شده است
// ===================================================================
interface CustomModalProps {
  isOpen: boolean;
  onClose: () => void;
  onSave: (name: string, instructions: string) => void;
  initialName: string;
  initialInstructions: string;
}

const CustomModal: FC<CustomModalProps> = ({ isOpen, onClose, onSave, initialName, initialInstructions }) => {
  const [name, setName] = useState(initialName);
  const [instructions, setInstructions] = useState(initialInstructions);

  useEffect(() => {
    if (isOpen) {
      setName(initialName);
      setInstructions(initialInstructions);
    }
  }, [isOpen, initialName, initialInstructions]);

  if (!isOpen) return null;

  const handleSave = () => {
    if (name.trim() && instructions.trim()) {
      onSave(name.trim(), instructions.trim());
      onClose();
    } else {
      alert("لطفا نام و دستورالعمل‌ها را وارد کنید.");
    }
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          <h3>ساخت شخصیت اختصاصی</h3>
          <button className="close-button" onClick={onClose}>×</button>
        </div>
        <div className="modal-body">
          <div className="form-group">
            <label htmlFor="name">نام شما (برای صدا زدن)</label>
            <input id="name" type="text" placeholder="مثلا: علی" value={name} onChange={(e) => setName(e.target.value)} />
          </div>
          <div className="form-group">
            <label htmlFor="instructions">دستورالعمل‌های شخصیت</label>
            <textarea id="instructions" placeholder="مثلا: خیلی خودمونی و بامزه صحبت کن..." value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={6} />
            <small>اینجا فقط رفتار و سبک صحبت کردن دستیار را مشخص کنید.</small>
          </div>
        </div>
        <div className="modal-footer">
          <button className="save-button" onClick={handleSave}>ذخیره و فعال‌سازی</button>
        </div>
      </div>
    </div>
  );
};
// ===================================================================
// END: تعریف کامپوننت مودال
// ===================================================================


// کامپوننت منوی شخصیت‌ها (به صورت یک کامپوننت داخلی ساده)
const PersonalityMenu: React.FC<{ isOpen: boolean; onClose: () => void; onSelect: (p: PersonalityType) => void; }> = ({ isOpen, onClose, onSelect }) => {
  const menuRef = useRef<HTMLDivElement>(null);
  const { selectedPersonality } = useAppContext();
  const personalityIcons: Record<PersonalityType, string> = { default: "person", teacher: "school", poetic: "auto_awesome", funny: "sentiment_satisfied", custom: "tune" };
  const personalityLabels: Record<PersonalityType, string> = { default: 'دستیار پیش‌فرض', teacher: 'استاد زبان', poetic: 'حس خوب', funny: 'شوخ‌طبع', custom: 'شخصیت اختصاصی' };

  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => menuRef.current && !menuRef.current.contains(e.target as Node) && onClose();
    if (isOpen) document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div ref={menuRef} className="personality-popover-wrapper open">
      <div className="popover-content">
        <ul>
          {(Object.keys(personalityIcons) as PersonalityType[]).map(key => (
            <li key={key} className={cn({ active: selectedPersonality === key })} onClick={() => onSelect(key)}>
              <div><span className="material-symbols-outlined">{personalityIcons[key]}</span>{personalityLabels[key]}</div>
              {selectedPersonality === key && <span className="material-symbols-outlined tick">done</span>}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

// کامپوننت داخلی اصلی برنامه
const AppInternal: React.FC = () => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const { volume, changePersonality, customUserName, customInstructions } = useAppContext();
  const [isUserSpeaking, setIsUserSpeaking] = useState(false);
  const [isMicActive, setIsMicActive] = useState(false);
  const [isCamActive, setIsCamActive] = useState(false);
  const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user');
  const [isNotificationOpen, setIsNotificationOpen] = useState(false);
  const [isPersonalityMenuOpen, setIsPersonalityMenuOpen] = useState(false);
  const [isCustomModalOpen, setIsCustomModalOpen] = useState(false);

  const handleSelectPersonality = (p: PersonalityType) => {
    setIsPersonalityMenuOpen(false);
    if (p === 'custom') setIsCustomModalOpen(true);
    else changePersonality(p);
  };

  return (
    <>
      <div className="main-wrapper">
        <div className="header-controls">
          <button aria-label="انتخاب شخصیت" className="header-icon-button" onClick={() => setIsPersonalityMenuOpen(true)}>
            <span className="material-symbols-outlined">psychology</span>
          </button>
          <button aria-label="اطلاعات" className="header-icon-button" onClick={() => setIsNotificationOpen(v => !v)}>
            <span className="material-symbols-outlined">info</span>
          </button>
        </div>

        {isNotificationOpen && <div className="notification-popover-wrapper open"><div className="popover-content">مدل‌های هوش مصنوعی می‌توانند اشتباه کنند.</div></div>}
        
        <PersonalityMenu isOpen={isPersonalityMenuOpen} onClose={() => setIsPersonalityMenuOpen(false)} onSelect={handleSelectPersonality} />

        <div className="media-area">
          <video id="video-feed" ref={videoRef} autoPlay playsInline className={cn({ hidden: !isCamActive }, { "scale-x-[-1]": currentFacingMode === 'user' })} />
          {isMicActive && !isCamActive && <div id="large-logo-container"><Logo isMini={false} isActive={true} isAi={false} speakingVolume={volume} isUserSpeaking={isUserSpeaking} /></div>}
        </div>

        <ControlTray videoRef={videoRef} onUserSpeakingChange={setIsUserSpeaking} isAppMicActive={isMicActive} onAppMicToggle={setIsMicActive} isAppCamActive={isCamActive} onAppCamToggle={setIsCamActive} currentFacingMode={currentFacingMode} onFacingModeChange={setCurrentFacingMode} />
      </div>
      <CustomModal isOpen={isCustomModalOpen} onClose={() => setIsCustomModalOpen(false)} onSave={(name, instructions) => changePersonality('custom', { name, instructions })} initialName={customUserName} initialInstructions={customInstructions} />
    </>
  );
};

// کامپوننت ریشه App
function App() {
  const [showIOSModal, setShowIOSModal] = useState(false);
  const [personalityInstructions, setPersonalityInstructions] = useState<PersonalityInstructions | null>(null);
  const [loadingError, setLoadingError] = useState<string | null>(null);

  useEffect(() => {
    if (isIOS()) setShowIOSModal(true);
    const fetchInstructions = async () => {
      try {
        const res = await fetch('/api/instructions');
        if (!res.ok) throw new Error(`Network error: ${res.status}`);
        setPersonalityInstructions(await res.json());
      } catch (e) {
        setLoadingError("امکان دریافت تنظیمات شخصیت‌ها وجود ندارد. لطفاً صفحه را رفرش کنید.");
      }
    };
    fetchInstructions();
  }, []);

  if (loadingError) return <div className="loading-screen">{loadingError}</div>;
  if (!personalityInstructions) return <div className="loading-screen">در حال بارگذاری...</div>;

  const initialAppConfig: LiveConfig = { model: "models/gemini-2.0-flash-exp" };

  return (
    <AppProvider initialConfig={initialAppConfig} personalityInstructions={personalityInstructions}>
      <AppInternal />
      <IOSModal isOpen={showIOSModal} onClose={() => setShowIOSModal(false)} />
    </AppProvider>
  );
}

export default App;