Spaces:
Sleeping
Sleeping
humanda5
commited on
Commit
·
b5ff045
1
Parent(s):
2fe1b55
정상 작동, 수정 완료
Browse files- app.py +11 -2
- npc_social_network/README.txt +23 -1
- npc_social_network/data/saves/simulation_state.pkl +0 -0
- npc_social_network/data/vectorstores/alice.faiss +0 -0
- npc_social_network/data/vectorstores/bob.faiss +0 -0
- npc_social_network/data/vectorstores/charlie.faiss +0 -0
- npc_social_network/data/vectorstores/diana.faiss +0 -0
- npc_social_network/data/vectorstores/elin.faiss +0 -0
- npc_social_network/models/llm_helper.py +17 -1
- npc_social_network/npc/npc_base.py +2 -2
- npc_social_network/npc/npc_manager.py +17 -13
- npc_social_network/npc/npc_planner.py +5 -35
- npc_social_network/simulation_core.py +6 -4
- npc_social_network/templates/dashboard.html +2 -2
app.py
CHANGED
@@ -4,6 +4,7 @@ from flask import Flask, render_template, url_for, redirect
|
|
4 |
# from npc_social_network import simulation_core
|
5 |
# from stock.routes.stock_route import stock_bp
|
6 |
import threading
|
|
|
7 |
|
8 |
# -------------------------------------------------------------------
|
9 |
# 파일 1: portfolio/app.py (수정)
|
@@ -15,6 +16,14 @@ import threading
|
|
15 |
init_lock = threading.Lock()
|
16 |
simulation_initialized = False
|
17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
def create_app():
|
19 |
"""Flask 앱을 생성하고, 필요한 Blueprint를 등록하는 팩토리 함수."""
|
20 |
app = Flask(__name__,
|
@@ -33,8 +42,6 @@ def create_app():
|
|
33 |
print("="*60)
|
34 |
traceback.print_exc() # 에러의 전체 경로를 출력합니다.
|
35 |
print("="*60)
|
36 |
-
# 이 에러가 해결될 때까지 서버는 정상 작동하지 않을 수 있습니다.
|
37 |
-
# --- FINAL DEBUGGING TOOL END ---
|
38 |
|
39 |
|
40 |
# 포트폴리오의 메인 랜딩 페이지
|
@@ -62,6 +69,8 @@ if __name__ == '__main__':
|
|
62 |
print("="*60)
|
63 |
|
64 |
# 3. Flask 웹 서버 실행
|
|
|
|
|
65 |
# use_reloader=False는 디버그 모드에서 앱이 두 번 실행되는 것을 방지하여,
|
66 |
# 시뮬레이션 스레드가 두 번 시작되지 않도록 합니다.
|
67 |
app.run(debug=True, use_reloader=False, port=5000)
|
|
|
4 |
# from npc_social_network import simulation_core
|
5 |
# from stock.routes.stock_route import stock_bp
|
6 |
import threading
|
7 |
+
import logging
|
8 |
|
9 |
# -------------------------------------------------------------------
|
10 |
# 파일 1: portfolio/app.py (수정)
|
|
|
16 |
init_lock = threading.Lock()
|
17 |
simulation_initialized = False
|
18 |
|
19 |
+
# 특정 로그 메시지를 걸러내기 위한 필터 클래스
|
20 |
+
class LogFilter(logging.Filter):
|
21 |
+
def filter(self, record):
|
22 |
+
# /api/world_state 경로에 대한 로그는 기록하지 않음
|
23 |
+
if "/api/world_state" in record.getMessage():
|
24 |
+
return False
|
25 |
+
return True
|
26 |
+
|
27 |
def create_app():
|
28 |
"""Flask 앱을 생성하고, 필요한 Blueprint를 등록하는 팩토리 함수."""
|
29 |
app = Flask(__name__,
|
|
|
42 |
print("="*60)
|
43 |
traceback.print_exc() # 에러의 전체 경로를 출력합니다.
|
44 |
print("="*60)
|
|
|
|
|
45 |
|
46 |
|
47 |
# 포트폴리오의 메인 랜딩 페이지
|
|
|
69 |
print("="*60)
|
70 |
|
71 |
# 3. Flask 웹 서버 실행
|
72 |
+
log = logging.getLogger('werkzeug')
|
73 |
+
log.addFilter(LogFilter())
|
74 |
# use_reloader=False는 디버그 모드에서 앱이 두 번 실행되는 것을 방지하여,
|
75 |
# 시뮬레이션 스레드가 두 번 시작되지 않도록 합니다.
|
76 |
app.run(debug=True, use_reloader=False, port=5000)
|
npc_social_network/README.txt
CHANGED
@@ -233,4 +233,26 @@ Phase 5: 논문/연구 결과 정리 - 최종 과제
|
|
233 |
|
234 |
---
|
235 |
|
236 |
-
© 2025 NPC Social Network Simulation Project v3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
233 |
|
234 |
---
|
235 |
|
236 |
+
© 2025 NPC Social Network Simulation Project v3
|
237 |
+
|
238 |
+
---
|
239 |
+
3. 플레이어 개입 및 관계 형성 시스템
|
240 |
+
목표: 플레이어가 '신'의 관점에서 시뮬레이션에 직접 개입하여 새로운 관계나 사건을 만들어내는 기능을 구현합니다.
|
241 |
+
|
242 |
+
주요 기능:
|
243 |
+
|
244 |
+
관계 강제 설정: 플레이어가 두 NPC를 선택하여 '친구'나 '라이벌' 같은 관계를 즉시 설정합니다.
|
245 |
+
|
246 |
+
상황 연출 및 대화 시작: 장소, 시간, 주제 등을 설정하여 특정 NPC들 간의 만남과 대화를 강제로 시작시킬 수 있습니다. (예: "저녁에 선술집에서 밥과 앨리스가 돈 문제로 다투게 하라")
|
247 |
+
|
248 |
+
'소개' 기능: 플레이어가 A를 시켜, 서로 모르는 B와 C를 소개해주는 이벤트를 발생시킵니다.
|
249 |
+
---
|
250 |
+
5. UI/UX 안정성 및 편의성 개선
|
251 |
+
목표: 대시보드의 사용성을 개선합니다.
|
252 |
+
|
253 |
+
주요 기능:
|
254 |
+
|
255 |
+
네트워크 안정화: 관계도 업데이트 시 그래프가 심하게 흔들리거나 돌아가는 문제를 해결합니다.
|
256 |
+
|
257 |
+
레이아웃 정리: 화면 크기 변화에도 UI 요소들이 깨지지 않도록 CSS를 수정합니다.
|
258 |
+
---
|
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/diana.faiss
CHANGED
Binary files a/npc_social_network/data/vectorstores/diana.faiss and b/npc_social_network/data/vectorstores/diana.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/models/llm_helper.py
CHANGED
@@ -157,4 +157,20 @@ Do not include any other text, explanations, or markdown.
|
|
157 |
except json.JSONDecodeError:
|
158 |
# 4단계: 최종 실패 시, 안전한 기본값 반환
|
159 |
print(f"LLM 자가 교정 실패. 원본 응답: {response_text}")
|
160 |
-
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
157 |
except json.JSONDecodeError:
|
158 |
# 4단계: 최종 실패 시, 안전한 기본값 반환
|
159 |
print(f"LLM 자가 교정 실패. 원본 응답: {response_text}")
|
160 |
+
return {}
|
161 |
+
|
162 |
+
def generate_plan_from_goal(npc_name: str, goal: str) -> List[str]:
|
163 |
+
"""목표가 주어지면, 가디언 함수를 이용해 안정적으로 계획을 생성"""
|
164 |
+
prompt = f"""
|
165 |
+
# Instruction
|
166 |
+
You are an AI assistant for an NPC named '{npc_name}'.
|
167 |
+
The NPC's goal is: "{goal}"
|
168 |
+
create a 3 to 5 step action plan to achieve this goal.
|
169 |
+
Your response MUST be a valid JSON array of strings, where each string is a step in the plan.
|
170 |
+
|
171 |
+
# Plan (JSON array of strings only):
|
172 |
+
"""
|
173 |
+
result = _query_llm_for_json_robustly(prompt)
|
174 |
+
if isinstance(result, list) and all(isinstance(s, str) for s in result):
|
175 |
+
return result
|
176 |
+
return ["[계획 생성에 실패했습니다]"] # 최종 실패 시 안전한 값 반환
|
npc_social_network/npc/npc_base.py
CHANGED
@@ -489,7 +489,7 @@ class NPC:
|
|
489 |
)
|
490 |
|
491 |
if gossip_dialogue and "[LLM Error]" not in gossip_dialogue:
|
492 |
-
|
493 |
gossip_to_spread.is_shared = True
|
494 |
|
495 |
# [✨ 수정] 2. '소문 전파'라는 행동에 대한 명확한 기억을 별도로 생성
|
@@ -560,7 +560,7 @@ class NPC:
|
|
560 |
emotion = "rumination", # 반추, 성찰과 관련된 감정
|
561 |
memory_type="Symbolic",
|
562 |
)
|
563 |
-
|
564 |
|
565 |
def update_reputation(self, target_name: str, score_change: int):
|
566 |
"""특정 대상에 대한 평판 점수를 변경"""
|
|
|
489 |
)
|
490 |
|
491 |
if gossip_dialogue and "[LLM Error]" not in gossip_dialogue:
|
492 |
+
simulation_core.add_log(f"[소문 전파] {self.korean_name} -> {target_npc.korean_name}: {gossip_dialogue}")
|
493 |
gossip_to_spread.is_shared = True
|
494 |
|
495 |
# [✨ 수정] 2. '소문 전파'라는 행동에 대한 명확한 기억을 별도로 생성
|
|
|
560 |
emotion = "rumination", # 반추, 성찰과 관련된 감정
|
561 |
memory_type="Symbolic",
|
562 |
)
|
563 |
+
simulation_core.add_log(f"[{self.korean_name}]의 새로운 깨달음: {symbolic_thought}")
|
564 |
|
565 |
def update_reputation(self, target_name: str, score_change: int):
|
566 |
"""특정 대상에 대한 평판 점수를 변경"""
|
npc_social_network/npc/npc_manager.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
# portfolio/npc_social_network/npc/npc_manager.py
|
2 |
import random
|
3 |
from typing import TYPE_CHECKING, List, Optional
|
|
|
4 |
|
5 |
if TYPE_CHECKING:
|
6 |
from .npc_base import NPC
|
@@ -67,7 +68,7 @@ class NPCManager:
|
|
67 |
initiator_postposition = get_korean_postposition(initiator.korean_name, "이", "가")
|
68 |
target_postposition = get_korean_postposition(target.korean_name, "에게", "에게")
|
69 |
|
70 |
-
|
71 |
|
72 |
if topic:
|
73 |
# 주제가 있는 경우
|
@@ -106,30 +107,33 @@ class NPCManager:
|
|
106 |
"""
|
107 |
initial_utterance = query_llm_with_prompt(prompt).strip()
|
108 |
|
109 |
-
#
|
|
|
|
|
|
|
110 |
name_prefix = f"{initiator.korean_name}:"
|
111 |
-
if
|
112 |
-
|
113 |
|
114 |
-
# 따옴표도
|
115 |
-
|
116 |
|
117 |
-
if "[LLM Error]" in
|
|
|
118 |
print(f"[{initiator.korean_name}] 대화 시작에 실패했습니다.")
|
119 |
return None, None, None
|
120 |
|
121 |
-
|
122 |
|
123 |
# 2. Target이 Initiator의 말을 듣고 응답 생성
|
124 |
# generate_dialogue 함수를 재사용하되, target_npc 인자를 전달
|
125 |
response_utterance = target.generate_dialogue(
|
126 |
-
user_input=
|
127 |
time_context = time_context,
|
128 |
target_npc=initiator
|
129 |
)
|
130 |
|
131 |
-
|
132 |
-
print("-------------------------\n")
|
133 |
|
134 |
# 3. 대화가 끝난 후, 목격자를 선정하여 소문을 생성합니다.
|
135 |
potential_witnesses = [npc for npc in self.npcs if npc not in [initiator, target]]
|
@@ -150,7 +154,7 @@ class NPCManager:
|
|
150 |
memory_type="Gossip",
|
151 |
)
|
152 |
# 로그는 simulation_core의 add_log를 직접 호출할 수 없으므로 print로 대체
|
153 |
-
|
154 |
|
155 |
-
return initiator, target,
|
156 |
|
|
|
1 |
# portfolio/npc_social_network/npc/npc_manager.py
|
2 |
import random
|
3 |
from typing import TYPE_CHECKING, List, Optional
|
4 |
+
from .. import simulation_core
|
5 |
|
6 |
if TYPE_CHECKING:
|
7 |
from .npc_base import NPC
|
|
|
68 |
initiator_postposition = get_korean_postposition(initiator.korean_name, "이", "가")
|
69 |
target_postposition = get_korean_postposition(target.korean_name, "에게", "에게")
|
70 |
|
71 |
+
simulation_core.add_log(f"\n---[NPC 상호작용 이벤트]---\n{initiator.korean_name}{initiator_postposition} {target.korean_name}{target_postposition} 상호작용을 시도합니다.")
|
72 |
|
73 |
if topic:
|
74 |
# 주제가 있는 경우
|
|
|
107 |
"""
|
108 |
initial_utterance = query_llm_with_prompt(prompt).strip()
|
109 |
|
110 |
+
# 따옴표도 제거
|
111 |
+
clean_utterance = initial_utterance.strip('"')
|
112 |
+
|
113 |
+
# 이름 접두사가 있는지 확인하고 제거
|
114 |
name_prefix = f"{initiator.korean_name}:"
|
115 |
+
if clean_utterance.startswith(name_prefix):
|
116 |
+
clean_utterance = clean_utterance[len(name_prefix):].strip()
|
117 |
|
118 |
+
# 따옴표도 제거
|
119 |
+
final_utterance = clean_utterance.strip().strip('"')
|
120 |
|
121 |
+
if "[LLM Error]" in final_utterance or not final_utterance:
|
122 |
+
simulation_core.add_log("[Error: {initiator.korean_name} 대화 시작 실패]")
|
123 |
print(f"[{initiator.korean_name}] 대화 시작에 실패했습니다.")
|
124 |
return None, None, None
|
125 |
|
126 |
+
simulation_core.add_log(f"[{initiator.korean_name}]: {final_utterance}")
|
127 |
|
128 |
# 2. Target이 Initiator의 말을 듣고 응답 생성
|
129 |
# generate_dialogue 함수를 재사용하되, target_npc 인자를 전달
|
130 |
response_utterance = target.generate_dialogue(
|
131 |
+
user_input=final_utterance,
|
132 |
time_context = time_context,
|
133 |
target_npc=initiator
|
134 |
)
|
135 |
|
136 |
+
simulation_core.add_log(f"[{target.korean_name}]: {response_utterance}")
|
|
|
137 |
|
138 |
# 3. 대화가 끝난 후, 목격자를 선정하여 소문을 생성합니다.
|
139 |
potential_witnesses = [npc for npc in self.npcs if npc not in [initiator, target]]
|
|
|
154 |
memory_type="Gossip",
|
155 |
)
|
156 |
# 로그는 simulation_core의 add_log를 직접 호출할 수 없으므로 print로 대체
|
157 |
+
simulation_core.add_log(f"[목격] {witness.korean_name}{witness_postposition} {initiator.korean_name}와 {target.korean_name}의 대화를 목격함.")
|
158 |
|
159 |
+
return initiator, target, final_utterance
|
160 |
|
npc_social_network/npc/npc_planner.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
# portfolio/npc_social_network/npc/npc_planner.py
|
2 |
from .. import simulation_core
|
3 |
from typing import List, Optional, TYPE_CHECKING
|
4 |
-
from ..models.llm_helper import query_llm_with_prompt
|
5 |
import json
|
6 |
import re
|
7 |
|
@@ -102,47 +102,17 @@ class PlannerManager:
|
|
102 |
if not self.current_goal:
|
103 |
return
|
104 |
|
105 |
-
|
106 |
-
# Instruction
|
107 |
-
You are an AI assistant for an NPC named '{self.npc.korean_name}'.
|
108 |
-
The NPC's goal is: "{self.current_goal.description}"
|
109 |
-
Create a 3 to 5 step action plan to achieve this goal.
|
110 |
-
Your response MUST be a valid JSON array of strings, where each string is a step in the plan.
|
111 |
-
Do not include any other text, explanations, or markdown.
|
112 |
-
|
113 |
-
# Example Output
|
114 |
-
["첫 번째 계획 단계.", "두 번째 계획 단계.", "세 번째 계획 단계."]
|
115 |
-
|
116 |
-
# Plan (JSON array of strings only):
|
117 |
-
"""
|
118 |
-
plan_text = query_llm_with_prompt(prompt).strip()
|
119 |
-
|
120 |
-
if not plan_text or "[LLM Error]" in plan_text:
|
121 |
-
return
|
122 |
-
|
123 |
-
try:
|
124 |
-
# JSON 응답을 직접 파싱하여 파이썬 리스트로 변환
|
125 |
-
json_match = re.search(r'\[.*\]', plan_text, re.DOTALL)
|
126 |
-
if not json_match:
|
127 |
-
raise json.JSONDecodeError("No JSON array found", plan_text, 0)
|
128 |
-
|
129 |
-
# 추출된 JSON 문자열 파싱
|
130 |
-
cleaned_steps = json.loads(json_match.group())
|
131 |
-
if not isinstance(cleaned_steps, list) or not all(isinstance(s, str) for s in cleaned_steps):
|
132 |
-
raise json.JSONDecodeError("Invalid format", plan_text, 0)
|
133 |
-
except (json.JSONDecodeError, TypeError):
|
134 |
-
print(f"[{self.npc.korean_name}] 계획 생성 실패: LLM이 유효한 JSON을 반환하지 않았습니다.")
|
135 |
-
print(f"LLM 응답: {plan_text}")
|
136 |
-
self.current_plan = None
|
137 |
-
return
|
138 |
|
139 |
# 행동하기
|
140 |
-
if cleaned_steps:
|
141 |
with simulation_core.simulation_lock:
|
142 |
self.current_plan = Plan(goal=self.current_goal, steps=cleaned_steps)
|
143 |
print(f"[{self.npc.korean_name}의 계획 수립]")
|
144 |
for i, step in enumerate(self.current_plan.steps):
|
145 |
print(f" - {i+1}단계: {step}")
|
|
|
|
|
146 |
|
147 |
def complete_step(self):
|
148 |
"""현재 단꼐를 완료하고 다음 단계로 진행. 계획이 모두 끝나면 모표를 완료 처리"""
|
|
|
1 |
# portfolio/npc_social_network/npc/npc_planner.py
|
2 |
from .. import simulation_core
|
3 |
from typing import List, Optional, TYPE_CHECKING
|
4 |
+
from ..models.llm_helper import query_llm_with_prompt, generate_plan_from_goal
|
5 |
import json
|
6 |
import re
|
7 |
|
|
|
102 |
if not self.current_goal:
|
103 |
return
|
104 |
|
105 |
+
cleaned_steps = generate_plan_from_goal(self.npc.korean_name, self.current_goal.description)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
106 |
|
107 |
# 행동하기
|
108 |
+
if cleaned_steps and cleaned_steps[0] != "[계획 생성에 실패했습니다]":
|
109 |
with simulation_core.simulation_lock:
|
110 |
self.current_plan = Plan(goal=self.current_goal, steps=cleaned_steps)
|
111 |
print(f"[{self.npc.korean_name}의 계획 수립]")
|
112 |
for i, step in enumerate(self.current_plan.steps):
|
113 |
print(f" - {i+1}단계: {step}")
|
114 |
+
else:
|
115 |
+
print(f"[{self.npc.korean_name}] 계획 생성에 최종 실패했습니다.")
|
116 |
|
117 |
def complete_step(self):
|
118 |
"""현재 단꼐를 완료하고 다음 단계로 진행. 계획이 모두 끝나면 모표를 완료 처리"""
|
npc_social_network/simulation_core.py
CHANGED
@@ -24,9 +24,11 @@ def add_log(message):
|
|
24 |
"""이벤트 로그에 메시지를 추가합니다."""
|
25 |
timestamp = time.strftime("%H:%M:%S")
|
26 |
print(f"[{timestamp}] {message}")
|
27 |
-
event_log.
|
28 |
-
|
29 |
-
|
|
|
|
|
30 |
|
31 |
def tick_simulation():
|
32 |
"""시뮬레이션의 시간을 한 단계 진행"""
|
@@ -36,7 +38,7 @@ def tick_simulation():
|
|
36 |
if simulation_paused: # 정지 상태에서는 틱 실행 x
|
37 |
return
|
38 |
|
39 |
-
|
40 |
|
41 |
# --- 자율 행동 및 상호작용은 이제 NPC가 스스로 결정 ---
|
42 |
for npc in npc_manager.all():
|
|
|
24 |
"""이벤트 로그에 메시지를 추가합니다."""
|
25 |
timestamp = time.strftime("%H:%M:%S")
|
26 |
print(f"[{timestamp}] {message}")
|
27 |
+
event_log.append(f"[{timestamp}] {message}\n")
|
28 |
+
|
29 |
+
# 로그가 지정 수를 넘으면 가장 오래된 로그를 제거
|
30 |
+
if len(event_log) > 300:
|
31 |
+
event_log.pop(0)
|
32 |
|
33 |
def tick_simulation():
|
34 |
"""시뮬레이션의 시간을 한 단계 진행"""
|
|
|
38 |
if simulation_paused: # 정지 상태에서는 틱 실행 x
|
39 |
return
|
40 |
|
41 |
+
print("시뮬레이션 틱 시작")
|
42 |
|
43 |
# --- 자율 행동 및 상호작용은 이제 NPC가 스스로 결정 ---
|
44 |
for npc in npc_manager.all():
|
npc_social_network/templates/dashboard.html
CHANGED
@@ -24,7 +24,7 @@
|
|
24 |
.info-panel { background: var(--panel-bg-color); padding: 20px; border-radius: 12px; box-shadow: var(--shadow); overflow-y: auto; }
|
25 |
#npc-details { flex-basis: 45%; }
|
26 |
#log-panel { flex-basis: 55%; display: flex; flex-direction: column; }
|
27 |
-
#log-container { flex: 1; overflow-y: auto; font-size: 13px; line-height: 1.6; color: #495057; }
|
28 |
h2 { margin-top: 0; margin-bottom: 15px; font-size: 18px; color: #343a40; }
|
29 |
</style>
|
30 |
</head>
|
@@ -149,7 +149,7 @@
|
|
149 |
|
150 |
function updateLog(logMessages) {
|
151 |
logContainer.textContent = logMessages.join('\n');
|
152 |
-
logContainer.scrollTop =
|
153 |
}
|
154 |
|
155 |
function updatePlayPauseButton(isPaused) {
|
|
|
24 |
.info-panel { background: var(--panel-bg-color); padding: 20px; border-radius: 12px; box-shadow: var(--shadow); overflow-y: auto; }
|
25 |
#npc-details { flex-basis: 45%; }
|
26 |
#log-panel { flex-basis: 55%; display: flex; flex-direction: column; }
|
27 |
+
#log-container { flex: 1; overflow-y: auto; font-size: 13px; line-height: 1.6; color: #495057; white-space: pre-wrap; }
|
28 |
h2 { margin-top: 0; margin-bottom: 15px; font-size: 18px; color: #343a40; }
|
29 |
</style>
|
30 |
</head>
|
|
|
149 |
|
150 |
function updateLog(logMessages) {
|
151 |
logContainer.textContent = logMessages.join('\n');
|
152 |
+
logContainer.scrollTop = logContainer.scrollHeight;
|
153 |
}
|
154 |
|
155 |
function updatePlayPauseButton(isPaused) {
|