humanda5 commited on
Commit
b03bfdc
·
1 Parent(s): 3d47d2b

배포 및 최종 수정 완료 버전

Browse files
app.py CHANGED
@@ -22,6 +22,8 @@ class LogFilter(logging.Filter):
22
  # /api/world_state 경로에 대한 로그는 기록하지 않음
23
  if "/api/world_state" in record.getMessage():
24
  return False
 
 
25
  return True
26
 
27
  def create_app():
@@ -48,23 +50,11 @@ def create_app():
48
  if __name__ == '__main__':
49
  # 1. flask 앱 실행
50
  app = create_app()
51
- # 2. NPC 시뮬레이션 초기화 및 백그라운드 스레드 시작
52
- try:
53
- from npc_social_network import simulation_core
54
- simulation_core.initialize_simulation()
55
- print("✅ 시뮬레이션이 성공적으로 초기화되었습니다.")
56
- except Exception as e:
57
- import traceback
58
- print("="*60)
59
- print("❌ CRITICAL ERROR: 시뮬레이션 초기화 중 에러가 발생했습니다!")
60
- print(f" 에러 메시지: {e}")
61
- print("="*60)
62
- traceback.print_exc()
63
- print("="*60)
64
-
65
- # 3. Flask 웹 서버 실행
66
  log = logging.getLogger('werkzeug')
67
  log.addFilter(LogFilter())
 
68
  # use_reloader=False는 디버그 모드에서 앱이 두 번 실행되는 것을 방지하여,
69
  # 시뮬레이션 스레드가 두 번 시작되지 않도록 합니다.
70
  app.run(debug=True, use_reloader=False, port=5000)
 
22
  # /api/world_state 경로에 대한 로그는 기록하지 않음
23
  if "/api/world_state" in record.getMessage():
24
  return False
25
+ if "/api/npc_details" in record.getMessage():
26
+ return False
27
  return True
28
 
29
  def create_app():
 
50
  if __name__ == '__main__':
51
  # 1. flask 앱 실행
52
  app = create_app()
53
+
54
+ # Flask 앱이 실행되기 전에 로그 필터를 적용합니다.
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  log = logging.getLogger('werkzeug')
56
  log.addFilter(LogFilter())
57
+
58
  # use_reloader=False는 디버그 모드에서 앱이 두 번 실행되는 것을 방지하여,
59
  # 시뮬레이션 스레드가 두 번 시작되지 않도록 합니다.
60
  app.run(debug=True, use_reloader=False, port=5000)
npc_social_network/data/saves/simulation_state.pkl CHANGED
Binary files a/npc_social_network/data/saves/simulation_state.pkl and b/npc_social_network/data/saves/simulation_state.pkl differ
 
npc_social_network/data/vectorstores/alice.faiss CHANGED
Binary files a/npc_social_network/data/vectorstores/alice.faiss and b/npc_social_network/data/vectorstores/alice.faiss differ
 
npc_social_network/data/vectorstores/bob.faiss CHANGED
Binary files a/npc_social_network/data/vectorstores/bob.faiss and b/npc_social_network/data/vectorstores/bob.faiss differ
 
npc_social_network/data/vectorstores/charlie.faiss CHANGED
Binary files a/npc_social_network/data/vectorstores/charlie.faiss and b/npc_social_network/data/vectorstores/charlie.faiss differ
 
npc_social_network/data/vectorstores/elin.faiss CHANGED
Binary files a/npc_social_network/data/vectorstores/elin.faiss and b/npc_social_network/data/vectorstores/elin.faiss differ
 
npc_social_network/manager/conversation_manager.py CHANGED
@@ -97,6 +97,9 @@ class ConversationManager:
97
 
98
  simulation_core.add_log(f"[{initiator.korean_name}, {target.korean_name}] 대화 경험이 기억과 관계, 성격에 반영되었습니다.")
99
 
 
 
 
100
  except Exception as e:
101
  print(f"[에러] 백그라운드 기억 저장 중 문제 발생: {e}")
102
  import traceback
@@ -137,10 +140,6 @@ class ConversationManager:
137
  )
138
  background_thread.start()
139
 
140
- # 3. 대화가 종료된 직후, 현재까지의 시뮬레이션 전체 상태를 즉시 저장.
141
- with simulation_core.simulation_lock:
142
- simulation_core.save_simulation(simulation_core.npc_manager)
143
-
144
 
145
  def next_turn(self):
146
  """대화의 다음 턴을 진행, 생성된 대사 처리"""
 
97
 
98
  simulation_core.add_log(f"[{initiator.korean_name}, {target.korean_name}] 대화 경험이 기억과 관계, 성격에 반영되었습니다.")
99
 
100
+ # 9. 모든 백그라운드 작업 종료 후, 현재까지의 시뮬레이션 전체 상태를 즉시 저장.
101
+ simulation_core.save_simulation(simulation_core.npc_manager)
102
+
103
  except Exception as e:
104
  print(f"[에러] 백그라운드 기억 저장 중 문제 발생: {e}")
105
  import traceback
 
140
  )
141
  background_thread.start()
142
 
 
 
 
 
143
 
144
  def next_turn(self):
145
  """대화의 다음 턴을 진행, 생성된 대사 처리"""
npc_social_network/models/gemini_setup.py CHANGED
@@ -4,10 +4,25 @@ from dotenv import load_dotenv
4
  import os
5
 
6
 
7
- def load_gemini():
8
- load_dotenv() # .env 파일에서 환경 변수 로드
9
 
10
- genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
11
- model = genai.GenerativeModel("gemini-2.0-flash") # Gemini Flash 사용
12
 
13
- return model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import os
5
 
6
 
7
+ # def load_gemini():
8
+ # load_dotenv() # .env 파일에서 환경 변수 로드
9
 
10
+ # genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
11
+ # model = genai.GenerativeModel("gemini-2.0-flash") # Gemini Flash 사용
12
 
13
+ # return model
14
+
15
+ def initialize_model(model_name: str, api_key: str):
16
+ """ 주어진 모델과 API 키로 Gemini 모델을 동적으로 초기화"""
17
+ try:
18
+ # API 키를 설정
19
+ genai.configure(api_key=api_key)
20
+ # 지정된 모델을 생성
21
+ model = genai.GenerativeModel(model_name)
22
+ # 간단한 테스트 호출로 API 키 유효성 검증
23
+ model.generate_content("hello", generation_config={"max_output_tokens": 1})
24
+ print(f"✅ Gemini 모델 '{model_name}'이(가) 성공적으로 초기화되었습니다.")
25
+ return model
26
+ except Exception as e:
27
+ print(f"❌ Gemini 모델 초기화 실패: {e}")
28
+ return None
npc_social_network/models/llm_helper.py CHANGED
@@ -1,20 +1,19 @@
1
  # npc_social_network/models/llm_helper.py
2
 
3
  from npc_social_network.npc.emotion_config import EMOTION_LIST
4
- from npc_social_network.models.gemini_setup import load_gemini
5
  from npc_social_network.npc.npc_memory import Memory
 
6
  from typing import List
7
  import json
8
  import re
 
 
9
  from typing import TYPE_CHECKING, List
10
 
11
  if TYPE_CHECKING:
12
  from ..npc.npc_base import NPC
13
  from ..manager.conversation_manager import Conversation
14
 
15
- # Gemini 모델 초기화
16
- gemini_model = load_gemini()
17
-
18
  def query_llm_for_emotion(user_input):
19
  """
20
  LLM을 통해 플레이어 입력에서 감정 추출
@@ -28,7 +27,7 @@ def query_llm_for_emotion(user_input):
28
  반드시 감정 이름을 한 개만 출력하세요. (예: joy)
29
  """
30
  try:
31
- response = gemini_model.generate_content(prompt)
32
  return response.text.strip()
33
  except Exception as e:
34
  return f"[LLM Error] {str(e)}"
@@ -36,11 +35,57 @@ def query_llm_for_emotion(user_input):
36
  def query_llm_with_prompt(prompt: str) -> str:
37
  """
38
  prompt 문자열을 받아 LLM 호출
 
39
  """
 
 
 
40
  try:
41
- response = gemini_model.generate_content(prompt)
 
42
  return response.text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  except Exception as e:
 
44
  return f"[LLM Error] {str(e)}"
45
 
46
  def summarize_text(text: str) -> str:
@@ -163,6 +208,8 @@ def _query_llm_for_json_robustly(prompt: str) -> dict | list:
163
  else:
164
  text_to_parse = response_text # 최후의 경우 원본 텍스트 사용
165
 
 
 
166
  # 딕셔너리나 리스트의 마지막 요소 뒤에 붙은 꼬리표 쉼표 제거
167
  text_to_parse = re.sub(r',\s*(\}|\])', r'\1', text_to_parse)
168
 
@@ -183,6 +230,9 @@ Do not include any other text, explanations, or markdown.
183
  # Corrected JSON:
184
  """
185
  corrected_response = query_llm_with_prompt(correction_prompt)
 
 
 
186
  try:
187
  return json.loads(corrected_response) # 교정된 응답 파싱 시도
188
  except json.JSONDecodeError:
@@ -368,11 +418,11 @@ def evaluate_goal_achievement(initiator_name: str, goal: str, conversation_trans
368
  결과는 반드시 아래 JSON 형식이어야 합니다.
369
 
370
  # Evaluation (JSON format only)
371
- {
372
  "goal_achieved": True,
373
  "reason": "목표 달성 또는 실패에 대한 간결한 이유 (예: '상대방을 위로하고 지지하며 긍정적인 반응을 이끌어냈다.)",
374
  "initiator_emotion": "이 결과를 얻은 후 대화 시작자가 느낄 감정 (아래 'Emotion_List에서 찾아서 한 단어로 표현)
375
- }
376
 
377
  # Emotion_List
378
  {EMOTION_LIST}
 
1
  # npc_social_network/models/llm_helper.py
2
 
3
  from npc_social_network.npc.emotion_config import EMOTION_LIST
 
4
  from npc_social_network.npc.npc_memory import Memory
5
+ from .. import simulation_core
6
  from typing import List
7
  import json
8
  import re
9
+ import time
10
+ from google.api_core import exceptions as google_exceptions
11
  from typing import TYPE_CHECKING, List
12
 
13
  if TYPE_CHECKING:
14
  from ..npc.npc_base import NPC
15
  from ..manager.conversation_manager import Conversation
16
 
 
 
 
17
  def query_llm_for_emotion(user_input):
18
  """
19
  LLM을 통해 플레이어 입력에서 감정 추출
 
27
  반드시 감정 이름을 한 개만 출력하세요. (예: joy)
28
  """
29
  try:
30
+ response = simulation_core.active_llm_model.generate_content(prompt)
31
  return response.text.strip()
32
  except Exception as e:
33
  return f"[LLM Error] {str(e)}"
 
35
  def query_llm_with_prompt(prompt: str) -> str:
36
  """
37
  prompt 문자열을 받아 LLM 호출
38
+ - api 사용량 오류를 감지하고 자동 대처
39
  """
40
+ if not simulation_core.active_llm_model:
41
+ return "[LLM Error] API 모델이 정상적으로 설정되지 않았습니다."
42
+
43
  try:
44
+ # 1. 첫 번째 API 호출 시도
45
+ response = simulation_core.active_llm_model.generate_content(prompt)
46
  return response.text.strip()
47
+ except google_exceptions.ResourceExhausted as e:
48
+ error_message = str(e)
49
+
50
+ # 2. 오류 메시지를 분석하여 원인 파악
51
+ if "PerMinute" in error_message:
52
+ # -- 분당 사용량 초과 --
53
+ simulation_core.add_log("[API 경고] 분당 사용량을 초과했습니다. 60초 후 자동으로 재시도 합니다.")
54
+ time.sleep(60)
55
+
56
+ try:
57
+ # 2-1. 두 번째 API 호출 시도
58
+ simulation_core.add_log("[API 정보] API 호출을 재시도합니다.")
59
+ response = simulation_core.active_llm_model.generate_content(prompt)
60
+ return response.text.strip()
61
+ except Exception as retry_e:
62
+ # 재시도마저 실패할 경우, 최종 에러 반환
63
+ simulation_core.add_log("========= [API Error] =========")
64
+
65
+ # 시물레이션을 안전하게 '일시정지' 상태로 변경
66
+ with simulation_core.simulation_lock:
67
+ simulation_core.simulation_paused = True
68
+
69
+ return f"[API 에러] 재시도에 실패했습니다: {retry_e}"
70
+
71
+ elif "PerDay" in error_message:
72
+ # -- 일일 사용량 초과 시 --
73
+ simulation_core.add_log("[API Error] 일일 사용량을 모두 소진했습니다.")
74
+ simulation_core.add_log("다른 API 키를 사용하거나, 내일 다시 시도해주세요.")
75
+
76
+ # 시뮬레이션을 안전하게 '일시정지' 상태로 변경
77
+ with simulation_core.simulation_lock:
78
+ simulation_core.simulation_paused = True
79
+ return "[LLM Error] Daily quota exceeded"
80
+
81
+ else:
82
+ # 그 외 다른 ResourceExhausted 오류
83
+ with simulation_core.simulation_lock:
84
+ simulation_core.simulation_paused = True
85
+ return f"[LLM Error] {error_message}"
86
+
87
  except Exception as e:
88
+ # 그 외 모든 종류의 오류
89
  return f"[LLM Error] {str(e)}"
90
 
91
  def summarize_text(text: str) -> str:
 
208
  else:
209
  text_to_parse = response_text # 최후의 경우 원본 텍스트 사용
210
 
211
+ # 불리언 값의 대소문자를 표준에 맞게 수정 (True -> true)
212
+ text_to_parse = text_to_parse.replace(": True", ": true").replace(": False", ": false")
213
  # 딕셔너리나 리스트의 마지막 요소 뒤에 붙은 꼬리표 쉼표 제거
214
  text_to_parse = re.sub(r',\s*(\}|\])', r'\1', text_to_parse)
215
 
 
230
  # Corrected JSON:
231
  """
232
  corrected_response = query_llm_with_prompt(correction_prompt)
233
+ # 자가 교정된 텍스트도 한번 더 정리
234
+ corrected_response = corrected_response.replace(": True", ": true").replace(": False", ": false")
235
+ corrected_response = re.sub(r',\s*(\}|\])', r'\1', corrected_response)
236
  try:
237
  return json.loads(corrected_response) # 교정된 응답 파싱 시도
238
  except json.JSONDecodeError:
 
418
  결과는 반드시 아래 JSON 형식이어야 합니다.
419
 
420
  # Evaluation (JSON format only)
421
+ {{
422
  "goal_achieved": True,
423
  "reason": "목표 달성 또는 실패에 대한 간결한 이유 (예: '상대방을 위로하고 지지하며 긍정적인 반응을 이끌어냈다.)",
424
  "initiator_emotion": "이 결과를 얻은 후 대화 시작자가 느낄 감정 (아래 'Emotion_List에서 찾아서 한 단어로 표현)
425
+ }}
426
 
427
  # Emotion_List
428
  {EMOTION_LIST}
npc_social_network/routes/npc_route.py CHANGED
@@ -1,6 +1,7 @@
1
  # portfolio/npc_social_network/routes/npc_route.py
2
  from flask import Blueprint, render_template, request, jsonify, url_for
3
  from .. import simulation_core
 
4
  import os
5
 
6
 
@@ -8,15 +9,43 @@ import os
8
  npc_bp = Blueprint(
9
  "npc_social",
10
  __name__,
11
- static_folder="../static"
 
12
  )
13
 
14
  @npc_bp.route("/")
15
  def dashboard():
16
  return render_template("dashboard.html")
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  @npc_bp.route("/api/world_state", methods=['GET'])
19
  def get_world_state():
 
 
20
  with simulation_core.simulation_lock:
21
  if not simulation_core.npc_manager:
22
  return jsonify({"error": "Simulation not started"}), 500
@@ -26,7 +55,7 @@ def get_world_state():
26
 
27
  nodes = []
28
  for npc in all_npcs:
29
- # 각 NPC의 영어 ID를 기반으로 이미지 파일명을 생성합니다. (예: elin -> npc_elin.png)
30
  image_filename = f"images/npc/{npc.name}.png"
31
  # if not os.path.exists(image_filename):
32
  # image_filename = f"images/npc/npc.png"
@@ -78,8 +107,6 @@ def get_world_state():
78
 
79
  return jsonify({ "nodes": nodes, "edges": edges, "log": current_log, "paused": is_paused, "waiting_for_player": waiting_for_player, "player_conversation": player_conversation_info })
80
 
81
- # 다른 API 엔드포인트들은 이 테스트 동안에는 호출되지 않으므로 그대로 두어도 괜찮습니다.
82
-
83
  @npc_bp.route("/api/toggle_simulation", methods=['POST'])
84
  def toggle_simulation():
85
  with simulation_core.simulation_lock:
 
1
  # portfolio/npc_social_network/routes/npc_route.py
2
  from flask import Blueprint, render_template, request, jsonify, url_for
3
  from .. import simulation_core
4
+ from ..models.gemini_setup import initialize_model
5
  import os
6
 
7
 
 
9
  npc_bp = Blueprint(
10
  "npc_social",
11
  __name__,
12
+ static_folder="../static",
13
+ static_url_path="/npc_social_network"
14
  )
15
 
16
  @npc_bp.route("/")
17
  def dashboard():
18
  return render_template("dashboard.html")
19
 
20
+ # 시뮬레이션을 시작하는 API
21
+ @npc_bp.route("/api/initialize_simulation", methods=['POST'])
22
+ def api_initialize_simulation():
23
+ """API 키를 받아 시뮬레이션을 초기화하고 시작하는 API"""
24
+ from ..models.gemini_setup import initialize_model
25
+ data = request.json
26
+ model_name = data.get("model_name", "gemini-2.0-flash")
27
+ api_key = data.get("api_key")
28
+
29
+ if not api_key:
30
+ return jsonify({"success": False, "error": "API 키를 입력해주세요."}), 400
31
+
32
+ # 1. 모델 초기화 시도
33
+ new_model = initialize_model(model_name, api_key)
34
+
35
+ if new_model:
36
+ # 2. 성공시, 전역 모델을 설정하고 시뮬레이션을 시작
37
+ simulation_core.active_llm_model = new_model
38
+ if not simulation_core.simulation_initialized:
39
+ simulation_core.initialize_simulation()
40
+ simulation_core.start_simulation_loop()
41
+ return jsonify({"success": True, "message": f"'{model_name}' 모델로 시뮬레이션을 시작합니다."})
42
+ else:
43
+ return jsonify({"success": False, "error": "API 키가 유효하지 않거나 모델 초기화에 실패했습니다."}), 400
44
+
45
  @npc_bp.route("/api/world_state", methods=['GET'])
46
  def get_world_state():
47
+ if not simulation_core.simulation_initialized:
48
+ return jsonify({"status": "needs_initialization", "log": simulation_core.event_log})
49
  with simulation_core.simulation_lock:
50
  if not simulation_core.npc_manager:
51
  return jsonify({"error": "Simulation not started"}), 500
 
55
 
56
  nodes = []
57
  for npc in all_npcs:
58
+ # 각 NPC의 영어 ID를 기반으로 이미지 파일명을 생성합니다. (예: elin -> elin.png)
59
  image_filename = f"images/npc/{npc.name}.png"
60
  # if not os.path.exists(image_filename):
61
  # image_filename = f"images/npc/npc.png"
 
107
 
108
  return jsonify({ "nodes": nodes, "edges": edges, "log": current_log, "paused": is_paused, "waiting_for_player": waiting_for_player, "player_conversation": player_conversation_info })
109
 
 
 
110
  @npc_bp.route("/api/toggle_simulation", methods=['POST'])
111
  def toggle_simulation():
112
  with simulation_core.simulation_lock:
npc_social_network/simulation_core.py CHANGED
@@ -15,6 +15,8 @@ from typing import List, Optional
15
  # 1. 시뮬레이션 상태 (전역 변수)
16
  # - 이 변수들이 시뮬레이션 세계의 모든 정보를 담습니다.
17
  #------------------------------------------
 
 
18
  npc_manager: NPCManager = None
19
  simulation_paused = True
20
  event_log:List[str] = []
@@ -120,16 +122,23 @@ def simulation_loop():
120
  time.sleep(5) # 5초에 한 번씩 틱 발생 (이것보다 줄이면 Gemini API 사용량 초과 발생 가능)
121
 
122
  def initialize_simulation():
123
- """서버 시작 시 시뮬레이션을 초기화합니다."""
124
- global npc_manager, conversation_manager
 
 
 
 
 
125
  npc_manager = load_simulation()
126
  conversation_manager = ConversationManager()
127
  if npc_manager is None:
128
  npc_manager = setup_initial_scenario()
129
  save_simulation(npc_manager)
130
 
 
131
  add_log("시뮬레이션이 성공적으로 초기화되었습니다.")
132
 
 
133
  # 백그라운드 스레드에서 시뮬레이션 루프 시작
134
  simulation_thread = threading.Thread(target=simulation_loop, daemon=True)
135
  simulation_thread.start()
 
15
  # 1. 시뮬레이션 상태 (전역 변수)
16
  # - 이 변수들이 시뮬레이션 세계의 모든 정보를 담습니다.
17
  #------------------------------------------
18
+ simulation_initialized = False # 초기화 여부 플래그
19
+ active_llm_model = None # 현재 활성화된 LLM 모델
20
  npc_manager: NPCManager = None
21
  simulation_paused = True
22
  event_log:List[str] = []
 
122
  time.sleep(5) # 5초에 한 번씩 틱 발생 (이것보다 줄이면 Gemini API 사용량 초과 발생 가능)
123
 
124
  def initialize_simulation():
125
+ """시뮬레이션의 준비"""
126
+ global npc_manager, conversation_manager, simulation_initialized, active_llm_model
127
+
128
+ # 이미 초기화되었다면 다시 실행 x
129
+ if simulation_initialized:
130
+ return
131
+
132
  npc_manager = load_simulation()
133
  conversation_manager = ConversationManager()
134
  if npc_manager is None:
135
  npc_manager = setup_initial_scenario()
136
  save_simulation(npc_manager)
137
 
138
+ simulation_initialized = True
139
  add_log("시뮬레이션이 성공적으로 초기화되었습니다.")
140
 
141
+ def start_simulation_loop():
142
  # 백그라운드 스레드에서 시뮬레이션 루프 시작
143
  simulation_thread = threading.Thread(target=simulation_loop, daemon=True)
144
  simulation_thread.start()
templates/dashboard.html CHANGED
@@ -20,6 +20,14 @@
20
  #player-input-text { flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #ddd; }
21
  #player-send-btn { padding: 8px 15px; border-radius: 6px; border: none; cursor: pointer; color: white; background-color: var(--primary-color); }
22
 
 
 
 
 
 
 
 
 
23
  /* 전체 컨트롤 패널 (가장 바깥쪽) */
24
  #controls { flex-shrink: 0; padding: 12px; display: flex; flex-direction: column; gap: 15px; background-color: #3e3f41; border-radius: 0 0 12px 12px;}
25
 
@@ -54,6 +62,20 @@
54
  </style>
55
  </head>
56
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  <div id="main-container">
58
  <div id="network-panel">
59
  <div id="network"></div>
@@ -69,7 +91,7 @@
69
  <div id="main-controls" style="display: flex; gap: 10px; justify-content: flex-start;">
70
  <button id="play-pause-btn">Play / Pause</button>
71
  <button id="tick-btn">Next Tick</button>
72
- <button id="player-toggle-btn">플레이어 비활성화</button>
73
  <button id="fit-btn">관계도 보기</button>
74
  </div>
75
  <!-- 개입 기능 선택 -->
@@ -124,6 +146,13 @@
124
 
125
  <script>
126
  document.addEventListener('DOMContentLoaded', async function () {
 
 
 
 
 
 
 
127
  // UI 요소 가져오기
128
  const networkContainer = document.getElementById('network');
129
  const logContainer = document.getElementById('log-container');
@@ -172,12 +201,46 @@
172
  interaction: { hover: true, zoomView: true, dragView: true, minZoom: 0.2, maxZoom: 4.0 }
173
  };
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  fitBtn.addEventListener('click', () => {
176
  if (network) {
177
  network.fit();
178
  }
179
  });
180
-
181
  // 탭 버튼 클릭 이벤트
182
  tabEvent.addEventListener('click', () =>{
183
  tabEvent.classList.add('active');
@@ -213,7 +276,7 @@
213
  if (!npc1_name || !npc2_name || !situation || npc1_name === npc2_name) {
214
  return alert ('서로 다른 두 NPC와 상황을 모두 입력해주세요.');
215
  }
216
- await fetch('/npc_social_network/api/orchestrate_conversation', {
217
  method: 'POST',
218
  headers: { 'Content-Type': 'application/json' },
219
  body: JSON.stringify({ npc1_name, npc2_name, situation })
@@ -223,8 +286,14 @@
223
 
224
  async function updateWorld() {
225
  try {
226
- const response = await fetch('/npc_social_network/api/world_state');
227
  const data = await response.json();
 
 
 
 
 
 
228
  if (data.error) throw new Error(data.error);
229
 
230
  // 데이터셋을 직접 업데이트하면 vis-network가 알아서 변경사항을 감지하고 다시 그립니다.
@@ -280,7 +349,7 @@
280
  const npcName = selectedNodeObject.label; // 영어 ID가 아닌 한글 라벨로 요청
281
 
282
  try {
283
- const response = await fetch(`/npc_social_network/api/npc_details/${npcName}`);
284
  const details = await response.json();
285
  let detailsHtml = `<h3>${details.name} (${details.job}, ${details.age}세)</h3>`;
286
  detailsHtml += `<p><strong>성격:</strong> ${details.personality_summary}</p>`;
@@ -333,7 +402,7 @@
333
  if (!npc1_name || !npc2_name || npc1_name === npc2_name) {
334
  return alert('서로 다른 두 명의 NPC를 선택해주세요.');
335
  }
336
- await fetch('/npc_social_network/api/force_relationship', {
337
  method: 'POST',
338
  headers: { 'Content-Type': 'application/json' },
339
  body: JSON.stringify({ npc1_name, npc2_name, relationship_type })
@@ -343,16 +412,16 @@
343
 
344
  // 모든 이벤트 리스너 복구
345
  playPauseBtn.addEventListener('click', async () => {
346
- const response = await fetch('/npc_social_network/api/toggle_simulation', { method: 'POST' });
347
  const data = await response.json();
348
  updatePlayPauseButton(data.paused);
349
  });
350
- tickBtn.addEventListener('click', async () => { await fetch('/npc_social_network/api/manual_tick', { method: 'POST' }); await updateWorld(); });
351
  injectEventBtn.addEventListener('click', async () => {
352
  const npc_name = eventNpcSelect.value; // 이제 한글 이름이 전송됩니다.
353
  const event_text = eventTextInput.value;
354
  if (!npc_name || !event_text) return alert('NPC와 이벤트 내용을 모두 입력해주세요.');
355
- await fetch('/npc_social_network/api/inject_event', {
356
  method: 'POST',
357
  headers: { 'Content-Type': 'application/json' },
358
  body: JSON.stringify({ npc_name, event_text })
@@ -361,10 +430,10 @@
361
  await updateWorld();
362
  });
363
  playerToggleBtn.addEventListener('click', async () => {
364
- const response = await fetch('/npc_social_network/api/toggle_player', {method: 'POST'});
365
  const data = await response.json();
366
  // 버튼 텍스트 업데이트
367
- playerToggleBtn.textContent = data.player_is_active ? '플레이어 비활성화' : '플레이어 활성화';
368
  });
369
  // '전송' 버튼 이벤트 리스너
370
  playerSendBtn.addEventListener('click', async () => {
@@ -372,7 +441,7 @@
372
  if (!utterance) return;
373
 
374
  playerSendBtn.disabled = true;
375
- await fetch('/npc_social_network/api/player_response', {
376
  method: 'POST',
377
  headers: { 'Content-Type': 'application/json' },
378
  body: JSON.stringify({ utterance })
 
20
  #player-input-text { flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #ddd; }
21
  #player-send-btn { padding: 8px 15px; border-radius: 6px; border: none; cursor: pointer; color: white; background-color: var(--primary-color); }
22
 
23
+ /* API 설정 */
24
+ #api-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: flex; justify-content: center; align-items: center; z-index: 1000; }
25
+ #api-modal-content { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); width: 400px; text-align: center; }
26
+ #api-modal-content h2 { margin-top: 0; }
27
+ #api-modal-content select, #api-modal-content input, #api-modal-content button { width: 100%; padding: 12px; margin-top: 10px; box-sizing: border-box; border-radius: 6px; border: 1px solid #ccc; }
28
+ #api-modal-content button { background: var(--primary-color); color: white; cursor: pointer; font-weight: bold; }
29
+ .hidden { display: none !important; }
30
+
31
  /* 전체 컨트롤 패널 (가장 바깥쪽) */
32
  #controls { flex-shrink: 0; padding: 12px; display: flex; flex-direction: column; gap: 15px; background-color: #3e3f41; border-radius: 0 0 12px 12px;}
33
 
 
62
  </style>
63
  </head>
64
  <body>
65
+ <div id="api-modal-overlay">
66
+ <div id="api-modal-content">
67
+ <h2>시뮬레이션 시작</h2>
68
+ <p>사용할 LLM 모델과 API 키를 입력해주세요.</p>
69
+ <select id="llm-model-select">
70
+ <option value="gemini-2.0-flash">[추천] Gemini 2.0 Flash</option>
71
+ <option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
72
+ <option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
73
+ </select>
74
+ <input type="password" id="api-key-input" placeholder="API Key를 여기에 붙여넣으세요">
75
+ <button id="start-simulation-btn">시작하기</button>
76
+ <p id="api-modal-message" style="color: red; margin-top: 10px;"></p>
77
+ </div>
78
+ </div>
79
  <div id="main-container">
80
  <div id="network-panel">
81
  <div id="network"></div>
 
91
  <div id="main-controls" style="display: flex; gap: 10px; justify-content: flex-start;">
92
  <button id="play-pause-btn">Play / Pause</button>
93
  <button id="tick-btn">Next Tick</button>
94
+ <button id="player-toggle-btn">플레이어 상태: 비활성화</button>
95
  <button id="fit-btn">관계도 보기</button>
96
  </div>
97
  <!-- 개입 기능 선택 -->
 
146
 
147
  <script>
148
  document.addEventListener('DOMContentLoaded', async function () {
149
+ // API 입력
150
+ const apiModalOverlay = document.getElementById('api-modal-overlay');
151
+ const llmModelSelect = document.getElementById('llm-model-select');
152
+ const apiKeyInput = document.getElementById('api-key-input');
153
+ const startSimBtn = document.getElementById('start-simulation-btn');
154
+ const apiModalMsg = document.getElementById('api-modal-message');
155
+
156
  // UI 요소 가져오기
157
  const networkContainer = document.getElementById('network');
158
  const logContainer = document.getElementById('log-container');
 
201
  interaction: { hover: true, zoomView: true, dragView: true, minZoom: 0.2, maxZoom: 4.0 }
202
  };
203
 
204
+ // 시작 버튼 이벤트
205
+ startSimBtn.addEventListener('click', async () => {
206
+ const model_name = llmModelSelect.value;
207
+ const api_key = apiKeyInput.value;
208
+
209
+ if (!api_key) {
210
+ apiModalMsg.textContent = 'API 키를 입력해주세요.';
211
+ return;
212
+ }
213
+
214
+ startSimBtn.textContent = '인증 중...';
215
+ startSimBtn.disabled = true;
216
+ apiModalMsg.textContent = '';
217
+
218
+ const response = await fetch('/api/initialize_simulation', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ model_name, api_key })
222
+ });
223
+ const data = await response.json();
224
+
225
+ if (data.success) {
226
+ apiModalOverlay.classList.add('hidden'); // 모달 숨기기
227
+ // 시뮬레이션이 성공적으로 시작되었으므로, 이제부터 월드 상태를 주기적으로 업데이트합니다.
228
+ await updateWorld();
229
+ if (!autoUpdateInterval) { // 중복 실행 방지
230
+ autoUpdateInterval = setInterval(updateWorld, 5000);
231
+ }
232
+ } else {
233
+ apiModalMsg.textContent = `오류: ${data.error}`;
234
+ startSimBtn.textContent = '시작하기';
235
+ startSimBtn.disabled = false;
236
+ }
237
+ });
238
+
239
  fitBtn.addEventListener('click', () => {
240
  if (network) {
241
  network.fit();
242
  }
243
  });
 
244
  // 탭 버튼 클릭 이벤트
245
  tabEvent.addEventListener('click', () =>{
246
  tabEvent.classList.add('active');
 
276
  if (!npc1_name || !npc2_name || !situation || npc1_name === npc2_name) {
277
  return alert ('서로 다른 두 NPC와 상황을 모두 입력해주세요.');
278
  }
279
+ await fetch('/api/orchestrate_conversation', {
280
  method: 'POST',
281
  headers: { 'Content-Type': 'application/json' },
282
  body: JSON.stringify({ npc1_name, npc2_name, situation })
 
286
 
287
  async function updateWorld() {
288
  try {
289
+ const response = await fetch('/api/world_state');
290
  const data = await response.json();
291
+ // 서버가 아직 준비되지 않았음을 확인하고 대기
292
+ if (data.status === 'needs_initialization') {
293
+ console.log("시뮬레이션 초기화 대기 중...");
294
+ updateLog(data.log); // 초기 로그는 표시
295
+ return;
296
+ }
297
  if (data.error) throw new Error(data.error);
298
 
299
  // 데이터셋을 직접 업데이트하면 vis-network가 알아서 변경사항을 감지하고 다시 그립니다.
 
349
  const npcName = selectedNodeObject.label; // 영어 ID가 아닌 한글 라벨로 요청
350
 
351
  try {
352
+ const response = await fetch(`/api/npc_details/${npcName}`);
353
  const details = await response.json();
354
  let detailsHtml = `<h3>${details.name} (${details.job}, ${details.age}세)</h3>`;
355
  detailsHtml += `<p><strong>성격:</strong> ${details.personality_summary}</p>`;
 
402
  if (!npc1_name || !npc2_name || npc1_name === npc2_name) {
403
  return alert('서로 다른 두 명의 NPC를 선택해주세요.');
404
  }
405
+ await fetch('/api/force_relationship', {
406
  method: 'POST',
407
  headers: { 'Content-Type': 'application/json' },
408
  body: JSON.stringify({ npc1_name, npc2_name, relationship_type })
 
412
 
413
  // 모든 이벤트 리스너 복구
414
  playPauseBtn.addEventListener('click', async () => {
415
+ const response = await fetch('/api/toggle_simulation', { method: 'POST' });
416
  const data = await response.json();
417
  updatePlayPauseButton(data.paused);
418
  });
419
+ tickBtn.addEventListener('click', async () => { await fetch('/api/manual_tick', { method: 'POST' }); await updateWorld(); });
420
  injectEventBtn.addEventListener('click', async () => {
421
  const npc_name = eventNpcSelect.value; // 이제 한글 이름이 전송됩니다.
422
  const event_text = eventTextInput.value;
423
  if (!npc_name || !event_text) return alert('NPC와 이벤트 내용을 모두 입력해주세요.');
424
+ await fetch('/api/inject_event', {
425
  method: 'POST',
426
  headers: { 'Content-Type': 'application/json' },
427
  body: JSON.stringify({ npc_name, event_text })
 
430
  await updateWorld();
431
  });
432
  playerToggleBtn.addEventListener('click', async () => {
433
+ const response = await fetch('/api/toggle_player', {method: 'POST'});
434
  const data = await response.json();
435
  // 버튼 텍스트 업데이트
436
+ playerToggleBtn.textContent = data.player_is_active ? '플레이어 상태: 활성화' : '플레이어 상태: 비활성화';
437
  });
438
  // '전송' 버튼 이벤트 리스너
439
  playerSendBtn.addEventListener('click', async () => {
 
441
  if (!utterance) return;
442
 
443
  playerSendBtn.disabled = true;
444
+ await fetch('/api/player_response', {
445
  method: 'POST',
446
  headers: { 'Content-Type': 'application/json' },
447
  body: JSON.stringify({ utterance })