SOY NV AI commited on
Commit
7167b1a
·
1 Parent(s): f9e2a99

酉곗뼱 湲곕뒫 異붽?: ?щ윭 硫붿떆吏€ ?좏깮 諛??곗냽 ?쒖떆 湲곕뒫 援ы쁽

Browse files

- NovelProject 紐⑤뜽??viewer_enabled, viewer_selected_message_ids ?꾨뱶 異붽?
- ?뚰겕?ㅽ럹?댁뒪?먯꽌 酉곗뼱 ?쒖꽦??諛?硫붿떆吏€ ?좏깮 湲곕뒫 異붽?
- ?щ윭 硫붿떆吏€瑜??좏깮?섏뿬 酉곗뼱???쒖꽌?€濡??쒖떆?섎뒗 湲곕뒫 援ы쁽
- 怨듦컻 酉곗뼱 ?섏씠吏€(/creation/viewer/<project_id>) 異붽?
- ?좏깮??硫붿떆吏€?ㅼ쓣 ?섎굹濡??댁뼱???쒖떆?섎룄濡?媛쒖꽑
- 酉곗뼱 ?좏깮 踰꾪듉 湲곕낯 ?④? 泥섎━ (?쒖꽦???쒖뿉留??쒖떆)
- ?좏깮 痍⑥냼 湲곕뒫 異붽?

app.py CHANGED
@@ -5,11 +5,19 @@ import sys
5
  import os
6
  import logging
7
  from logging.handlers import RotatingFileHandler
 
8
 
9
  # UTF-8 인코딩 강제 설정
10
  if sys.platform == 'win32':
11
  sys.stdout.reconfigure(encoding='utf-8')
12
  sys.stderr.reconfigure(encoding='utf-8')
 
 
 
 
 
 
 
13
 
14
  from app import create_app
15
 
 
5
  import os
6
  import logging
7
  from logging.handlers import RotatingFileHandler
8
+ import asyncio
9
 
10
  # UTF-8 인코딩 강제 설정
11
  if sys.platform == 'win32':
12
  sys.stdout.reconfigure(encoding='utf-8')
13
  sys.stderr.reconfigure(encoding='utf-8')
14
+ # Windows에서 asyncio와 httpx/Flask 간의 'Event loop is closed' 에러 방지
15
+ try:
16
+ import nest_asyncio
17
+ nest_asyncio.apply()
18
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
19
+ except:
20
+ pass
21
 
22
  from app import create_app
23
 
app/__init__.py CHANGED
@@ -305,8 +305,14 @@ def create_app() -> Flask:
305
  logger.debug(f"404 에러(정적): {request.path} - {request.method}")
306
  else:
307
  logger.warning(f"404 에러: {request.path} - {request.method}")
308
-
309
- if request.path.startswith('/api/'):
 
 
 
 
 
 
310
  return jsonify({'error': gettext('no_resource', lang=session.get('lang', 'vi')), 'path': request.path}), 404
311
  return 'Not Found', 404
312
 
@@ -347,7 +353,15 @@ def create_app() -> Flask:
347
  body_str = str(body)
348
  if len(body_str) > 1000:
349
  body_str = body_str[:1000] + "..."
350
- logger.info(f"[요청 본문] {body_str}")
 
 
 
 
 
 
 
 
351
  except:
352
  pass
353
 
@@ -356,7 +370,13 @@ def create_app() -> Flask:
356
  """처리되지 않은 모든 예외 로깅"""
357
  import traceback
358
  err_trace = traceback.format_exc()
359
- logger.error(f"[Unhandled Exception] {str(e)}\n{err_trace}")
 
 
 
 
 
 
360
 
361
  # API 요청인 경우 JSON 응답
362
  if request.path.startswith('/api/'):
 
305
  logger.debug(f"404 에러(정적): {request.path} - {request.method}")
306
  else:
307
  logger.warning(f"404 에러: {request.path} - {request.method}")
308
+
309
+ # API 요청이거나 JSON을 기대하는 요청인 경우 JSON 응답 반환
310
+ # 경로에 '/api/'가 포함되어 있으면 API 요청으로 간주 (예: /api/, /creation/api/)
311
+ is_api_request = '/api/' in request.path
312
+ accepts_json = request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json'
313
+ is_json_request = request.is_json or request.content_type == 'application/json'
314
+
315
+ if is_api_request or accepts_json or is_json_request:
316
  return jsonify({'error': gettext('no_resource', lang=session.get('lang', 'vi')), 'path': request.path}), 404
317
  return 'Not Found', 404
318
 
 
353
  body_str = str(body)
354
  if len(body_str) > 1000:
355
  body_str = body_str[:1000] + "..."
356
+
357
+ # Windows 콘솔 인코딩 에러 방지 (Emoji 등 처리할 수 없는 문자를 안전하게 처리)
358
+ try:
359
+ # cp949로 인코딩 불가능한 문자를 '?'로 대체하여 안전하게 처리
360
+ safe_body_str = body_str.encode('cp949', errors='replace').decode('cp949', errors='replace')
361
+ logger.info(f"[요청 본문] {safe_body_str}")
362
+ except Exception:
363
+ # 로깅 실패가 서비스 중단으로 이어지지 않도록 예외 처리
364
+ pass
365
  except:
366
  pass
367
 
 
370
  """처리되지 않은 모든 예외 로깅"""
371
  import traceback
372
  err_trace = traceback.format_exc()
373
+ error_msg = f"[Unhandled Exception] {str(e)}\n{err_trace}"
374
+ logger.error(error_msg)
375
+ # 터미널에 직접 출력 (콘솔 확인용)
376
+ print("\n" + "="*80)
377
+ print("ERROR 발생!")
378
+ print(error_msg)
379
+ print("="*80 + "\n")
380
 
381
  # API 요청인 경우 JSON 응답
382
  if request.path.startswith('/api/'):
app/agent/agent.py CHANGED
@@ -1,11 +1,43 @@
1
  import asyncio
2
  import os
3
- from typing import Optional, Union
 
 
4
  from pydantic_ai import Agent, RunContext
5
  from pydantic_ai.models import Model
6
  from app.agent.deps import NovelWriterDeps
7
  from app.models.project_config import ProjectMode
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  def get_model() -> Union[str, Model]:
10
  """환경 변수에 따라 적절한 모델을 반환합니다."""
11
  if os.environ.get('OPENAI_API_KEY'):
@@ -21,7 +53,17 @@ def get_model() -> Union[str, Model]:
21
  novel_agent = Agent(
22
  get_model(),
23
  deps_type=NovelWriterDeps,
24
- system_prompt="당신은 전문 웹소설 작가 어시스턴트입니다."
 
 
 
 
 
 
 
 
 
 
25
  )
26
 
27
  @novel_agent.system_prompt
@@ -29,26 +71,59 @@ async def dynamic_system_prompt(ctx: RunContext[NovelWriterDeps]) -> str:
29
  """Fetch facts and context in parallel to build a dynamic system prompt."""
30
  project = ctx.deps.current_project
31
 
32
- # Zep(Context)과 Mem0(Facts) 데이터를 병렬로 조회
 
 
 
 
 
33
  context_task = ctx.deps.zep_client.get_session_context(project.project_id, "default_session")
34
  facts_task = ctx.deps.mem0_client.get_facts(project.project_id)
35
 
36
  context, facts = await asyncio.gather(context_task, facts_task)
37
 
38
- facts_str = "\n- ".join(facts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  prompt = f"""
41
  [현재 프로젝트: {project.title}]
42
  모드: {project.mode.value}
43
-
44
- [기존 설정 및 사실관계 (Mem0)]
45
  - {facts_str}
46
 
47
- [스토리 맥락 및 요약 (Zep)]
48
  {context}
49
 
50
- 위 정보를 바탕으로 소설 작성을 도와주세요.
51
- 일관성 있는 캐릭터와 전개를 유지하는 것이 가장 중요합니다.
52
  """
53
  return prompt
54
 
 
1
  import asyncio
2
  import os
3
+ import logging
4
+ from typing import Optional, Union, List, Dict
5
+ from pydantic import BaseModel, Field
6
  from pydantic_ai import Agent, RunContext
7
  from pydantic_ai.models import Model
8
  from app.agent.deps import NovelWriterDeps
9
  from app.models.project_config import ProjectMode
10
 
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # --- Pydantic 데이터 모델 정의 ---
14
+
15
+ class CharacterStatus(BaseModel):
16
+ """게임 시스템 형태의 캐릭터 상태 및 세계관 정보"""
17
+ current_time: str = Field(description="현재 시간 (예: D+3652 | 2153/07/15 | 22:30)")
18
+ location: str = Field(description="현재 위치 (예: 하층부 언더쉘, Sector-85)")
19
+ weather: str = Field(description="현재 날씨 또는 환경 상태 (예: 습하고 퀴퀴함)")
20
+ name: str = Field(description="캐릭터 이름")
21
+ affiliation: str = Field(description="소속 그룹 또는 세력")
22
+ stats: str = Field(description="캐릭터 능력치 및 숙련도 (예: 이능: [미정](F-)[0/100])")
23
+ hp_status: str = Field(description="체력 및 부상 상태 (예: (양호))")
24
+ inventory: List[str] = Field(default_factory=list, description="소지품 목록")
25
+ quest_status: Optional[str] = Field(None, description="진행 중인 퀘스트 또는 목표")
26
+
27
+ class NovelGenerationResult(BaseModel):
28
+ """에이전트의 최종 응답 구조"""
29
+ narrative: str = Field(description="소설 본문 및 묘사 텍스트 (상태창 정보를 포함하지 마세요)")
30
+ status: CharacterStatus = Field(description="구조화된 캐릭터 상태 정보")
31
+ hidden_thoughts: Optional[str] = Field(None, description="에이전트의 내부 추론 또는 향후 전개 계획 (사용자에게는 보이지 않음)")
32
+ image_description: Optional[str] = Field(None, description="현재 캐릭터와 상황을 분석한 이미지 설명 (예: 'cyberpunk style, female character with dark hair, wearing tactical gear, in underground facility, neon lights, action scene')")
33
+
34
+ class InitialAnalysisResult(BaseModel):
35
+ """초기 설정 분석 결과 구조"""
36
+ summary: str = Field(description="작품의 핵심 배경과 로그라인 요약 (3~5문장)")
37
+ characters: List[str] = Field(description="추출된 주요 캐릭터 설정 리스트 (이름, 역할, 특징 포함)")
38
+ world_setting: List[str] = Field(description="추출된 핵심 세계관 및 환경 설정 리스트")
39
+ plot_hooks: List[str] = Field(description="향후 전개를 위한 주요 복선 및 갈등 요소")
40
+
41
  def get_model() -> Union[str, Model]:
42
  """환경 변수에 따라 적절한 모델을 반환합니다."""
43
  if os.environ.get('OPENAI_API_KEY'):
 
53
  novel_agent = Agent(
54
  get_model(),
55
  deps_type=NovelWriterDeps,
56
+ output_type=NovelGenerationResult,
57
+ system_prompt=(
58
+ "당신은 전문 웹소설 작가이자 게임 마스터(GM)입니다. "
59
+ "당신은 반드시 'NovelGenerationResult' 스키마를 엄격히 준수하여 응답해야 합니다.\n\n"
60
+ "1. 'narrative' 필드에는 독자가 몰입할 수 있는 소설 본문을 작성하세요. 본문 내에 상태창이나 통계 수치를 포함하지 마세요.\n"
61
+ "2. 'status' 필드에는 서사 전개에 맞춰 업데이트된 캐릭터의 게임적 상태 정보를 구조화하여 입력하세요.\n"
62
+ "3. 'hidden_thoughts' 필드에는 다음 전개를 위한 복선이나 에이전트의 의도를 기록하세요.\n"
63
+ "4. 'image_description' 필드에는 현재 캐릭터의 이름, 외모, 복장, 위치, 상황, 분위기 등을 분석하여 이미지 생성에 적합한 영어 설명을 작성하세요. "
64
+ "예: 'cyberpunk style, [캐릭터 이름], [외모 특징], wearing [복장], in [위치], [분위기/조명], [액션/상황]'. "
65
+ "반드시 현재 status의 캐릭터 정보(name, location, affiliation 등)와 narrative 내용을 기반으로 정확하게 작성하세요."
66
+ )
67
  )
68
 
69
  @novel_agent.system_prompt
 
71
  """Fetch facts and context in parallel to build a dynamic system prompt."""
72
  project = ctx.deps.current_project
73
 
74
+ # 초기 설정 가져오기 (DB에서 직접 조회)
75
+ from app.database import NovelProject
76
+ project_db = NovelProject.query.filter_by(project_id=project.project_id).first()
77
+ initial_settings = project_db.initial_settings if project_db and project_db.initial_settings else None
78
+
79
+ # Zep(Context - Narrative Flow만)과 Mem0(Facts - 정적 설정) 데이터를 병렬로 조회
80
  context_task = ctx.deps.zep_client.get_session_context(project.project_id, "default_session")
81
  facts_task = ctx.deps.mem0_client.get_facts(project.project_id)
82
 
83
  context, facts = await asyncio.gather(context_task, facts_task)
84
 
85
+ # Zep summary 잘림 감지 및 경고
86
+ truncation_markers = ['...', '…', '...\n', '…\n']
87
+ context_trimmed = context.rstrip()
88
+ is_truncated = any(context_trimmed.endswith(marker.rstrip()) for marker in truncation_markers)
89
+
90
+ if is_truncated:
91
+ logger.warning(
92
+ f"[Zep Memory Truncation Warning] Project {project.project_id}: "
93
+ f"Zep summary appears to be truncated. "
94
+ f"Summary length: {len(context)}, "
95
+ f"Ends with: {repr(context_trimmed[-10:])}. "
96
+ f"Consider reducing static world lore in Zep and storing it in Mem0/RAG instead."
97
+ )
98
+
99
+ facts_str = "\n- ".join(facts) if facts else "아직 저장된 정적 설정이 없습니다."
100
+
101
+ # 초기 설정 섹션 구성
102
+ initial_settings_section = ""
103
+ if initial_settings:
104
+ initial_settings_section = f"""
105
+ [초기 설정]
106
+ {initial_settings}
107
+
108
+ """
109
+ else:
110
+ initial_settings_section = """
111
+ [초기 설정]
112
+ 초기 설정이 아직 저장되지 않았습니다.
113
+
114
+ """
115
 
116
  prompt = f"""
117
  [현재 프로젝트: {project.title}]
118
  모드: {project.mode.value}
119
+ {initial_settings_section}
120
+ [정적 세계 설정 및 사실관계 (Mem0/RAG)]
121
  - {facts_str}
122
 
123
+ [서사적 전개 맥락 (Zep - Narrative Flow)]
124
  {context}
125
 
126
+ 위 정보를 바탕으로 소설 작성을 도와주세요.
 
127
  """
128
  return prompt
129
 
app/core/logger.py CHANGED
@@ -43,14 +43,40 @@ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
43
  # 콘솔 핸들러 설정
44
  stream = sys.stdout
45
  if sys.platform == 'win32':
46
- for s in [sys.stdout, sys.stderr]:
47
- if hasattr(s, 'reconfigure'):
48
- try:
49
- s.reconfigure(errors='replace')
50
- except Exception:
51
- pass
 
 
 
52
 
53
- console_handler = logging.StreamHandler(stream)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  console_handler.setLevel(level)
55
  console_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
56
  console_handler.setFormatter(console_formatter)
 
43
  # 콘솔 핸들러 설정
44
  stream = sys.stdout
45
  if sys.platform == 'win32':
46
+ # Windows 콘솔에서 유니코드 출력 문제 해결 (cp949 대신 utf-8 또는 에러 대체 설정)
47
+ try:
48
+ # sys.stdout/stderr의 에러 처리 방식을 'replace'로 설정
49
+ if hasattr(sys.stdout, 'reconfigure'):
50
+ sys.stdout.reconfigure(errors='replace')
51
+ if hasattr(sys.stderr, 'reconfigure'):
52
+ sys.stderr.reconfigure(errors='replace')
53
+ except Exception:
54
+ pass
55
 
56
+ class SafeStreamHandler(logging.StreamHandler):
57
+ """Windows cp949 인코딩 에러를 방지하는 안전한 스트림 핸들러"""
58
+ def emit(self, record):
59
+ try:
60
+ msg = self.format(record)
61
+ stream = self.stream
62
+ # 스트림의 인코딩 정보를 가져오거나 기본값 사용
63
+ encoding = getattr(stream, 'encoding', None) or 'utf-8'
64
+
65
+ # 인코딩 에러 방지: 처리할 수 없는 문자를 '?'로 대체
66
+ try:
67
+ # 먼저 현재 인코딩으로 시도
68
+ stream.write(msg + self.terminator)
69
+ self.flush()
70
+ except UnicodeEncodeError:
71
+ # 인코딩 에러 발생 시 안전하게 처리
72
+ safe_msg = msg.encode(encoding, errors='replace').decode(encoding, errors='replace')
73
+ stream.write(safe_msg + self.terminator)
74
+ self.flush()
75
+ except Exception:
76
+ # 모든 예외를 무시하여 로깅 실패가 애플리케이션을 중단시키지 않도록 함
77
+ pass
78
+
79
+ console_handler = SafeStreamHandler(stream)
80
  console_handler.setLevel(level)
81
  console_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
82
  console_handler.setFormatter(console_formatter)
app/database.py CHANGED
@@ -83,6 +83,9 @@ class NovelProject(db.Model):
83
  mode = db.Column(db.String(20), nullable=False, default='CREATIVE') # 'CREATIVE', 'REFERENCE'
84
  reference_source_id = db.Column(db.String(100), nullable=True)
85
  initial_settings = db.Column(db.Text, nullable=True) # 초기 세계관/설정 정보
 
 
 
86
  user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
87
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
88
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -99,6 +102,9 @@ class NovelProject(db.Model):
99
  'mode': self.mode,
100
  'reference_source_id': self.reference_source_id,
101
  'initial_settings': self.initial_settings,
 
 
 
102
  'user_id': self.user_id,
103
  'created_at': self.created_at.isoformat() if self.created_at else None,
104
  'updated_at': self.updated_at.isoformat() if self.updated_at else None
 
83
  mode = db.Column(db.String(20), nullable=False, default='CREATIVE') # 'CREATIVE', 'REFERENCE'
84
  reference_source_id = db.Column(db.String(100), nullable=True)
85
  initial_settings = db.Column(db.Text, nullable=True) # 초기 세계관/설정 정보
86
+ custom_system_prompt = db.Column(db.Text, nullable=True) # 작가 전용 시스템 지침 (커스텀 프롬프트)
87
+ viewer_enabled = db.Column(db.Boolean, default=False, nullable=False) # 뷰어 활성화 여부
88
+ viewer_selected_message_ids = db.Column(db.Text, nullable=True) # 선택된 메시지 ID 목록 (JSON 배열)
89
  user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
90
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
91
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
 
102
  'mode': self.mode,
103
  'reference_source_id': self.reference_source_id,
104
  'initial_settings': self.initial_settings,
105
+ 'custom_system_prompt': self.custom_system_prompt,
106
+ 'viewer_enabled': self.viewer_enabled,
107
+ 'viewer_selected_message_ids': self.viewer_selected_message_ids,
108
  'user_id': self.user_id,
109
  'created_at': self.created_at.isoformat() if self.created_at else None,
110
  'updated_at': self.updated_at.isoformat() if self.updated_at else None
app/migrations.py CHANGED
@@ -121,7 +121,25 @@ def check_and_migrate_db(app):
121
  logger.error(f"webtoon_milestone_manager 마이그레이션 중 오류: {e}")
122
  conn.rollback()
123
 
124
- # 5. notion_schedule_cache 테이블 확인 생성
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  if 'notion_schedule_cache' not in table_names:
126
  logger.info("notion_schedule_cache 테이블이 없어 생성합니다.")
127
  try:
@@ -131,6 +149,19 @@ def check_and_migrate_db(app):
131
  except Exception as e:
132
  logger.error(f"notion_schedule_cache 테이블 생성 실패: {e}")
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  logger.info("데이터베이스 마이그레이션 완료")
135
 
136
  except Exception as e:
 
121
  logger.error(f"webtoon_milestone_manager 마이그레이션 중 오류: {e}")
122
  conn.rollback()
123
 
124
+ # 5. chat_session 테이블 novel_project_id 컬럼 추가
125
+ cs_columns = []
126
+ try:
127
+ cs_columns = [col['name'] for col in inspector.get_columns('chat_session')]
128
+ except Exception as e:
129
+ logger.warning(f"'chat_session' 테이블 컬럼 조회 실패: {e}")
130
+
131
+ if 'chat_session' in table_names or 'ChatSession' in table_names:
132
+ if 'novel_project_id' not in cs_columns:
133
+ logger.info("chat_session 테이블에 'novel_project_id' 컬럼이 없어 추가합니다.")
134
+ try:
135
+ conn.execute(text("ALTER TABLE chat_session ADD COLUMN novel_project_id INTEGER REFERENCES novel_project(id)"))
136
+ conn.commit()
137
+ logger.info("'novel_project_id' 컬럼 추가 완료")
138
+ except Exception as e:
139
+ logger.error(f"'novel_project_id' 컬럼 추가 실패: {e}")
140
+ conn.rollback()
141
+
142
+ # 6. notion_schedule_cache 테이블 확인 및 생성
143
  if 'notion_schedule_cache' not in table_names:
144
  logger.info("notion_schedule_cache 테이블이 없어 생성합니다.")
145
  try:
 
149
  except Exception as e:
150
  logger.error(f"notion_schedule_cache 테이블 생성 실패: {e}")
151
 
152
+ # 7. novel_project 테이블에 custom_system_prompt 컬럼 추가
153
+ if 'novel_project' in table_names:
154
+ np_columns = [col['name'] for col in inspector.get_columns('novel_project')]
155
+ if 'custom_system_prompt' not in np_columns:
156
+ logger.info("novel_project 테이블에 'custom_system_prompt' 컬럼이 없어 추가합니다.")
157
+ try:
158
+ conn.execute(text("ALTER TABLE novel_project ADD COLUMN custom_system_prompt TEXT"))
159
+ conn.commit()
160
+ logger.info("'custom_system_prompt' 컬럼 추가 완료")
161
+ except Exception as e:
162
+ logger.error(f"'custom_system_prompt' 컬럼 추가 실패: {e}")
163
+ conn.rollback()
164
+
165
  logger.info("데이터베이스 마이그레이션 완료")
166
 
167
  except Exception as e:
app/routers/creation.py CHANGED
@@ -5,6 +5,7 @@ import uuid
5
  import os
6
  import asyncio
7
  import re
 
8
  from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact
9
  from app.routes import inject_admin_menu as shared_inject_admin_menu
10
  from app.models.project_config import ProjectConfig, ProjectMode
@@ -217,7 +218,7 @@ def get_chat_history(project_id: str):
217
 
218
  messages = ChatMessage.query.filter_by(session_id=session.id).order_by(ChatMessage.created_at.asc()).all()
219
  return jsonify({
220
- "messages": [{"role": m.role, "content": m.content} for m in messages]
221
  })
222
  except Exception as e:
223
  return jsonify({"error": str(e)}), 500
@@ -425,6 +426,27 @@ def save_initial_settings(project_id: str):
425
  current_app.logger.error(error_trace)
426
  return jsonify({"error": str(e)}), 500
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  @creation_bp.route('/api/projects/<project_id>/system-prompt', methods=['GET'])
429
  @login_required
430
  def get_system_prompt(project_id: str):
@@ -495,7 +517,16 @@ def get_system_prompt(project_id: str):
495
  current_app.logger.warning(f"[Get System Prompt] Mode conversion error: {e}, using default")
496
  mode_str = "CREATIVE"
497
 
 
 
 
 
 
 
 
 
498
  prompt = f"""
 
499
  [현재 프로젝트: {project.title}]
500
  모드: {mode_str}
501
  {initial_settings_section}
@@ -662,6 +693,7 @@ def chat_with_agent(project_id: str):
662
  "hidden_thoughts": gen_result.hidden_thoughts,
663
  "image_description": gen_result.image_description,
664
  "image_url": image_url,
 
665
  "usage": {
666
  "request_tokens": usage.request_tokens or usage.input_tokens,
667
  "response_tokens": usage.response_tokens or usage.output_tokens,
@@ -675,3 +707,134 @@ def chat_with_agent(project_id: str):
675
  error_trace = traceback.format_exc()
676
  current_app.logger.error(f"[WebNovel Chat Error] {str(e)}\n{error_trace}")
677
  return jsonify({"error": str(e), "trace": error_trace}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import os
6
  import asyncio
7
  import re
8
+ import json
9
  from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact
10
  from app.routes import inject_admin_menu as shared_inject_admin_menu
11
  from app.models.project_config import ProjectConfig, ProjectMode
 
218
 
219
  messages = ChatMessage.query.filter_by(session_id=session.id).order_by(ChatMessage.created_at.asc()).all()
220
  return jsonify({
221
+ "messages": [{"id": m.id, "role": m.role, "content": m.content} for m in messages]
222
  })
223
  except Exception as e:
224
  return jsonify({"error": str(e)}), 500
 
426
  current_app.logger.error(error_trace)
427
  return jsonify({"error": str(e)}), 500
428
 
429
+ @creation_bp.route('/api/projects/<project_id>/custom-prompt', methods=['POST'])
430
+ @login_required
431
+ def save_custom_prompt(project_id: str):
432
+ """프로젝트별 커스텀 시스템 지침(작가 프롬프트)을 저장합니다."""
433
+ try:
434
+ data = request.get_json()
435
+ prompt = data.get('custom_system_prompt')
436
+
437
+ project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
438
+ if not project:
439
+ return jsonify({"error": "Project not found"}), 404
440
+
441
+ project.custom_system_prompt = prompt
442
+ db.session.commit()
443
+
444
+ return jsonify({"message": "Custom system prompt saved successfully"})
445
+ except Exception as e:
446
+ db.session.rollback()
447
+ current_app.logger.error(f"[Save Custom Prompt Error] {str(e)}")
448
+ return jsonify({"error": str(e)}), 500
449
+
450
  @creation_bp.route('/api/projects/<project_id>/system-prompt', methods=['GET'])
451
  @login_required
452
  def get_system_prompt(project_id: str):
 
517
  current_app.logger.warning(f"[Get System Prompt] Mode conversion error: {e}, using default")
518
  mode_str = "CREATIVE"
519
 
520
+ # 커스텀 지침 섹션 추가
521
+ custom_prompt_section = ""
522
+ if project.custom_system_prompt:
523
+ custom_prompt_section = f"""
524
+ [작가 지정 지침 (Rules)]
525
+ {project.custom_system_prompt}
526
+ """
527
+
528
  prompt = f"""
529
+ {custom_prompt_section}
530
  [현재 프로젝트: {project.title}]
531
  모드: {mode_str}
532
  {initial_settings_section}
 
693
  "hidden_thoughts": gen_result.hidden_thoughts,
694
  "image_description": gen_result.image_description,
695
  "image_url": image_url,
696
+ "message_id": ai_msg.id, # 뷰어 선택을 위한 메시지 ID 추가
697
  "usage": {
698
  "request_tokens": usage.request_tokens or usage.input_tokens,
699
  "response_tokens": usage.response_tokens or usage.output_tokens,
 
707
  error_trace = traceback.format_exc()
708
  current_app.logger.error(f"[WebNovel Chat Error] {str(e)}\n{error_trace}")
709
  return jsonify({"error": str(e), "trace": error_trace}), 500
710
+
711
+ @creation_bp.route('/api/projects/<project_id>/viewer-settings', methods=['GET'])
712
+ @login_required
713
+ def get_viewer_settings(project_id: str):
714
+ """뷰어 설정을 조회합니다."""
715
+ try:
716
+ project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
717
+ if not project:
718
+ return jsonify({"error": "Project not found"}), 404
719
+
720
+ # JSON 배열로 저장된 메시지 ID 목록 파싱
721
+ selected_ids = []
722
+ if project.viewer_selected_message_ids:
723
+ try:
724
+ selected_ids = json.loads(project.viewer_selected_message_ids)
725
+ except:
726
+ selected_ids = []
727
+
728
+ return jsonify({
729
+ "viewer_enabled": project.viewer_enabled or False,
730
+ "viewer_selected_message_ids": selected_ids
731
+ })
732
+ except Exception as e:
733
+ current_app.logger.error(f"[Get Viewer Settings Error] {str(e)}")
734
+ return jsonify({"error": str(e)}), 500
735
+
736
+ @creation_bp.route('/api/projects/<project_id>/viewer-settings', methods=['POST'])
737
+ @login_required
738
+ def update_viewer_settings(project_id: str):
739
+ """뷰어 설정을 업데이트합니다."""
740
+ try:
741
+ data = request.get_json()
742
+ project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
743
+ if not project:
744
+ return jsonify({"error": "Project not found"}), 404
745
+
746
+ if 'viewer_enabled' in data:
747
+ project.viewer_enabled = bool(data['viewer_enabled'])
748
+
749
+ if 'viewer_selected_message_ids' in data:
750
+ # 여러 메시지 ID 배열 처리
751
+ message_ids = data['viewer_selected_message_ids']
752
+ if not isinstance(message_ids, list):
753
+ return jsonify({"error": "viewer_selected_message_ids must be an array"}), 400
754
+
755
+ from app.database import ChatSession, ChatMessage
756
+ session = ChatSession.query.filter_by(novel_project_id=project.id, user_id=current_user.id).first()
757
+ if not session:
758
+ return jsonify({"error": "No chat session found for this project"}), 404
759
+
760
+ valid_message_ids = []
761
+ for message_id in message_ids:
762
+ if not message_id:
763
+ continue
764
+
765
+ # 임시 ID인 경우 무시
766
+ if isinstance(message_id, str) and message_id.startswith('temp-'):
767
+ continue
768
+
769
+ # 메시지가 해당 프로젝트의 세션에 속하는지 확인
770
+ message = ChatMessage.query.filter_by(id=message_id, session_id=session.id, role='ai').first()
771
+ if message:
772
+ valid_message_ids.append(message.id)
773
+
774
+ # 생성 시간 순서로 정렬
775
+ if valid_message_ids:
776
+ messages = ChatMessage.query.filter(
777
+ ChatMessage.id.in_(valid_message_ids),
778
+ ChatMessage.session_id == session.id
779
+ ).order_by(ChatMessage.created_at.asc()).all()
780
+ valid_message_ids = [msg.id for msg in messages]
781
+ project.viewer_selected_message_ids = json.dumps(valid_message_ids)
782
+ else:
783
+ project.viewer_selected_message_ids = None
784
+
785
+ db.session.commit()
786
+
787
+ # JSON 배열로 저장된 메시지 ID 목록 파싱
788
+ selected_ids = []
789
+ if project.viewer_selected_message_ids:
790
+ try:
791
+ selected_ids = json.loads(project.viewer_selected_message_ids)
792
+ except:
793
+ selected_ids = []
794
+
795
+ return jsonify({
796
+ "viewer_enabled": project.viewer_enabled,
797
+ "viewer_selected_message_ids": selected_ids
798
+ })
799
+ except Exception as e:
800
+ db.session.rollback()
801
+ current_app.logger.error(f"[Update Viewer Settings Error] {str(e)}")
802
+ return jsonify({"error": str(e)}), 500
803
+
804
+ @creation_bp.route('/viewer/<project_id>', methods=['GET'])
805
+ def public_viewer(project_id: str):
806
+ """공개 뷰어 페이지 (인증 불필요, 활성화 여부 확인)"""
807
+ try:
808
+ project = NovelProject.query.filter_by(project_id=project_id).first()
809
+ if not project:
810
+ return "프로젝트를 찾을 수 없습니다.", 404
811
+
812
+ if not project.viewer_enabled:
813
+ return "뷰어가 비활성화되어 있습니다.", 403
814
+
815
+ # 선택된 메시지들 가져오기 (순서대로)
816
+ selected_messages = []
817
+ if project.viewer_selected_message_ids:
818
+ from app.database import ChatSession, ChatMessage
819
+ try:
820
+ message_ids = json.loads(project.viewer_selected_message_ids)
821
+ if message_ids:
822
+ # 생성 시간 순서로 정렬하여 가져오기
823
+ messages = ChatMessage.query.filter(
824
+ ChatMessage.id.in_(message_ids)
825
+ ).order_by(ChatMessage.created_at.asc()).all()
826
+ # ChatMessage 객체를 딕셔너리로 변환 (JSON 직렬화 가능하도록)
827
+ selected_messages = [{"id": msg.id, "content": msg.content, "created_at": msg.created_at.isoformat() if msg.created_at else None} for msg in messages]
828
+ except Exception as e:
829
+ current_app.logger.warning(f"[Public Viewer] Failed to parse message IDs: {e}")
830
+
831
+ # 디버깅 정보 로깅
832
+ current_app.logger.info(f"[Public Viewer] Project: {project_id}, Viewer enabled: {project.viewer_enabled}, Selected messages count: {len(selected_messages)}")
833
+
834
+ return render_template('novel_viewer.html',
835
+ project=project,
836
+ selected_messages=selected_messages)
837
+ except Exception as e:
838
+ import traceback
839
+ current_app.logger.error(f"[Public Viewer Error] {str(e)}\n{traceback.format_exc()}")
840
+ return f"오류가 발생했습니다: {str(e)}", 500
app/routes.py CHANGED
@@ -52,14 +52,28 @@ def get_default_admin_menu():
52
  ],
53
  },
54
  {
55
- "label": "사이트 관리",
56
  "roles": ["admin"],
57
  "items": [
58
- {"label": "설", "endpoint": "main.admin_settings"},
59
  {"label": "RAG 관리", "endpoint": "main.webnovels"},
 
 
 
 
 
 
 
 
 
 
60
  {"label": "사용자 관리", "endpoint": "main.admin", "roles": ["admin"]},
61
  {"label": "토큰 통계", "endpoint": "main.admin_tokens", "roles": ["admin"]},
62
  {"label": "메뉴 관리", "endpoint": "main.admin_menu", "roles": ["admin"]},
 
 
 
 
63
  ],
64
  },
65
  {
@@ -111,30 +125,6 @@ def get_default_admin_menu():
111
  {"label": "전체 마일스톤 보기", "endpoint": "main.webtoon_milestone_latest_result", "roles": ["admin", "webtoon_pm", "webtoon_admin", "producer"]},
112
  ]
113
  },
114
- {
115
- "label": "AI 설정",
116
- "roles": ["admin"],
117
- "items": [
118
- {"label": "AI 설정", "endpoint": "main.admin_settings"},
119
- {"label": "프롬프트 관리", "endpoint": "main.admin_prompts"},
120
- ],
121
- },
122
- {
123
- "label": "챗봇",
124
- "roles": ["admin"],
125
- "items": [
126
- {"label": "태그/프롬프트", "endpoint": "main.admin_tags"},
127
- {"label": "챗봇 프롬프트", "endpoint": "main.admin_chatbot_prompts"},
128
- ],
129
- },
130
- {
131
- "label": "편의기능",
132
- "roles": ["admin"],
133
- "items": [
134
- {"label": "유틸", "endpoint": "main.admin_utils"},
135
- {"label": "사진첩 관리", "endpoint": "main.admin_photo_album"},
136
- ],
137
- },
138
  {
139
  "label": "에이전트",
140
  "roles": ["admin"],
@@ -230,7 +220,7 @@ def get_available_admin_endpoints():
230
  ep = getattr(rule, "endpoint", None)
231
  if not ep or not isinstance(ep, str):
232
  continue
233
- if not ep.startswith("main."):
234
  continue
235
  # 메뉴용: methods에 GET이 포함된 화면/페이지 위주
236
  methods = set(getattr(rule, "methods", []) or [])
@@ -307,6 +297,10 @@ def merge_admin_menu_defaults(current_config: dict) -> dict:
307
  if not isinstance(sections, list):
308
  return base
309
 
 
 
 
 
310
  existing_by_label = {s.get("label"): s for s in sections if isinstance(s, dict) and isinstance(s.get("label"), str)}
311
 
312
  def _section_endpoints(sec: dict) -> set:
@@ -441,10 +435,10 @@ def inject_admin_menu():
441
 
442
  # 프로젝트 관리자(webtoon_pm) 혹은 웹툰 관리자(webtoon_admin)/프로듀서(producer)에게는 특정 섹션을 필터링
443
  if current_role in ['webtoon_admin', 'producer']:
444
- if sec_label not in ['관리팀', '원작 정보 분석']:
445
  continue
446
  elif current_role == 'webtoon_pm':
447
- if sec_label not in ['프로젝트 관리', '관리팀', '원작 정보 분석']:
448
  continue
449
 
450
  # '에이전트' 섹션은 오직 최고 관리자(admin) 권한만 노출
@@ -2081,7 +2075,7 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2081
 
2082
  # 모델명이 None이거나 빈 문자열인 경우 처리
2083
  if not model_name or not model_name.strip():
2084
- print(f"[Parent Chunk 생성] 오류: 모델명이 제공되지 않았습니다.")
2085
  return None
2086
 
2087
  # 텍스트가 너무 길면 일부만 사용 (최대 50000자)
@@ -2134,7 +2128,7 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2134
 
2135
  gemini_client = get_gemini_client()
2136
  if not gemini_client.is_configured():
2137
- print(f"[Parent Chunk 생성] 오류: Gemini API 키가 설정되지 않았습니다.")
2138
  print(f"[Parent Chunk 생성] 디버그: Gemini 클라이언트 상태 확인 중...")
2139
  # API 키 상태 다시 확인
2140
  from app.gemini_client import get_gemini_api_key
@@ -2154,12 +2148,12 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2154
  )
2155
 
2156
  if result['error']:
2157
- print(f"[Parent Chunk 생성] 오류: Gemini API 호출 실패 - {result['error']}")
2158
  print(f"[Parent Chunk 생성] 디버그: result 객체 내용: {result}")
2159
  return None
2160
 
2161
  if not result.get('response'):
2162
- print(f"[Parent Chunk 생성] 오류: Gemini API 응답이 비어있습니다.")
2163
  print(f"[Parent Chunk 생성] 디버그: result 객체 내용: {result}")
2164
  return None
2165
 
@@ -2205,11 +2199,11 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2205
  error_detail = ollama_response.text if ollama_response.text else '상세 정보 없음'
2206
  if ollama_response.status_code == 404:
2207
  error_msg = f'Ollama API 오류 404: 모델 "{model_name}"을(를) 찾을 수 없습니다. 모델이 Ollama에 설치되어 있는지 확인하세요.'
2208
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
2209
  print(f"[Parent Chunk 생성] 디버그: 만약 Gemini 모델을 사용하려면 모델명이 'gemini:' 또는 'gemini-'로 시작해야 합니다.")
2210
  else:
2211
  error_msg = f'Ollama API 오류: {ollama_response.status_code} - {error_detail[:200]}'
2212
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
2213
  return None
2214
 
2215
  response_data = ollama_response.json()
@@ -2227,15 +2221,15 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2227
  task_type='parent_chunk'
2228
  )
2229
  except requests.exceptions.Timeout:
2230
- print(f"[Parent Chunk 생성] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
2231
  print(f"[Parent Chunk 생성] 파일이 너무 크거나 모델 응답이 느릴 수 있습니다.")
2232
  return None
2233
  except requests.exceptions.ConnectionError:
2234
- print(f"[Parent Chunk 생성] Ollama 연결 오류: Ollama 서버에 연결할 수 없습니다.")
2235
  print(f"[Parent Chunk 생성] 디버그: Ollama URL: {OLLAMA_BASE_URL}")
2236
  return None
2237
  except requests.exceptions.RequestException as e:
2238
- print(f"[Parent Chunk 생성] Ollama API 오류: {str(e)}")
2239
  print(f"[Parent Chunk 생성] 디버그: Ollama URL: {OLLAMA_BASE_URL}")
2240
  return None
2241
 
@@ -2337,7 +2331,7 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2337
  db.session.add(parent_chunk)
2338
  db.session.commit()
2339
 
2340
- print(f"[Parent Chunk 생성] 완료: Parent Chunk가 생성되었습니다.")
2341
  print(f"[Parent Chunk 생성] - 세계관: {len(world_view)}자")
2342
  print(f"[Parent Chunk 생성] - 캐릭터: {len(characters)}자")
2343
  print(f"[Parent Chunk 생성] - 스토리: {len(story)}자")
@@ -2348,14 +2342,14 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
2348
 
2349
  except requests.exceptions.RequestException as e:
2350
  error_msg = f'Ollama API 연결 오류: {str(e)}'
2351
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
2352
  import traceback
2353
  traceback.print_exc()
2354
  return None
2355
  except Exception as e:
2356
  db.session.rollback()
2357
  error_msg = f'Parent Chunk 생성 중 오류: {str(e)}'
2358
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
2359
  import traceback
2360
  traceback.print_exc()
2361
  return None
@@ -12528,7 +12522,7 @@ def create_file_parent_chunk(file_id):
12528
  # 파일 경로 확인
12529
  if not file.file_path or not os.path.exists(file.file_path):
12530
  error_msg = f'파일 경로가 유효하지 않습니다: {file.file_path}'
12531
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
12532
  return jsonify({'error': error_msg}), 500
12533
 
12534
  # 파일 내용 읽기
@@ -12542,15 +12536,15 @@ def create_file_parent_chunk(file_id):
12542
  content = f.read()
12543
  except FileNotFoundError:
12544
  error_msg = f'파일을 찾을 수 없습니다: {file.file_path}'
12545
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
12546
  return jsonify({'error': error_msg}), 500
12547
  except PermissionError:
12548
  error_msg = f'파일 읽기 권한이 없습니다: {file.file_path}'
12549
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
12550
  return jsonify({'error': error_msg}), 500
12551
  except Exception as e:
12552
  error_msg = f'파일을 읽을 수 없습니다: {str(e)}'
12553
- print(f"[Parent Chunk 생성] 오류: {error_msg}")
12554
  import traceback
12555
  traceback.print_exc()
12556
  return jsonify({'error': error_msg}), 500
@@ -12584,7 +12578,7 @@ def create_file_parent_chunk(file_id):
12584
  import traceback
12585
  error_traceback = traceback.format_exc()
12586
  error_msg = str(e)
12587
- print(f"[Parent Chunk 생성] 예외 발생: {error_msg}")
12588
  print(f"[Parent Chunk 생성] Traceback:\n{error_traceback}")
12589
  return jsonify({
12590
  'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {error_msg}',
 
52
  ],
53
  },
54
  {
55
+ "label": "웹소설 관리",
56
  "roles": ["admin"],
57
  "items": [
58
+ {"label": "웹소 관리", "endpoint": "main.admin_webnovels"},
59
  {"label": "RAG 관리", "endpoint": "main.webnovels"},
60
+ {"label": "태그/프롬프트", "endpoint": "main.admin_tags"},
61
+ {"label": "챗봇 프롬프트", "endpoint": "main.admin_chatbot_prompts"},
62
+ ],
63
+ },
64
+ {
65
+ "label": "사이트 관리",
66
+ "roles": ["admin"],
67
+ "items": [
68
+ {"label": "AI 설정", "endpoint": "main.admin_settings"},
69
+ {"label": "프롬프트 관리", "endpoint": "main.admin_prompts"},
70
  {"label": "사용자 관리", "endpoint": "main.admin", "roles": ["admin"]},
71
  {"label": "토큰 통계", "endpoint": "main.admin_tokens", "roles": ["admin"]},
72
  {"label": "메뉴 관리", "endpoint": "main.admin_menu", "roles": ["admin"]},
73
+ {"label": "파일 관리", "endpoint": "main.admin_files"},
74
+ {"label": "메시지 확인", "endpoint": "main.admin_messages"},
75
+ {"label": "유틸", "endpoint": "main.admin_utils"},
76
+ {"label": "사진첩 관리", "endpoint": "main.admin_photo_album"},
77
  ],
78
  },
79
  {
 
125
  {"label": "전체 마일스톤 보기", "endpoint": "main.webtoon_milestone_latest_result", "roles": ["admin", "webtoon_pm", "webtoon_admin", "producer"]},
126
  ]
127
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  {
129
  "label": "에이전트",
130
  "roles": ["admin"],
 
220
  ep = getattr(rule, "endpoint", None)
221
  if not ep or not isinstance(ep, str):
222
  continue
223
+ if not ep.startswith("main.") and not ep.startswith("creation."):
224
  continue
225
  # 메뉴용: methods에 GET이 포함된 화면/페이지 위주
226
  methods = set(getattr(rule, "methods", []) or [])
 
297
  if not isinstance(sections, list):
298
  return base
299
 
300
+ # 특정 섹션(AI 설정, 챗봇, 편의기능) 삭제 및 하위 메뉴 이동 요청 반영
301
+ sections = [s for s in sections if isinstance(s, dict) and s.get("label") not in ["AI 설정", "챗봇", "편의기능"]]
302
+ current_config["sections"] = sections
303
+
304
  existing_by_label = {s.get("label"): s for s in sections if isinstance(s, dict) and isinstance(s.get("label"), str)}
305
 
306
  def _section_endpoints(sec: dict) -> set:
 
435
 
436
  # 프로젝트 관리자(webtoon_pm) 혹은 웹툰 관리자(webtoon_admin)/프로듀서(producer)에게는 특정 섹션을 필터링
437
  if current_role in ['webtoon_admin', 'producer']:
438
+ if sec_label not in ['창작', '관리팀', '원작 정보 분석', '웹소설 관리']:
439
  continue
440
  elif current_role == 'webtoon_pm':
441
+ if sec_label not in ['창작', '프로젝트 관리', '관리팀', '원작 정보 분석', '웹소설 관리']:
442
  continue
443
 
444
  # '에이전트' 섹션은 오직 최고 관리자(admin) 권한만 노출
 
2075
 
2076
  # 모델명이 None이거나 빈 문자열인 경우 처리
2077
  if not model_name or not model_name.strip():
2078
+ print(f"[Parent Chunk 생성] 오류: 모델명이 제공되지 않았습니다.")
2079
  return None
2080
 
2081
  # 텍스트가 너무 길면 일부만 사용 (최대 50000자)
 
2128
 
2129
  gemini_client = get_gemini_client()
2130
  if not gemini_client.is_configured():
2131
+ print(f"[Parent Chunk 생성] 오류: Gemini API 키가 설정되지 않았습니다.")
2132
  print(f"[Parent Chunk 생성] 디버그: Gemini 클라이언트 상태 확인 중...")
2133
  # API 키 상태 다시 확인
2134
  from app.gemini_client import get_gemini_api_key
 
2148
  )
2149
 
2150
  if result['error']:
2151
+ print(f"[Parent Chunk 생성] 오류: Gemini API 호출 실패 - {result['error']}")
2152
  print(f"[Parent Chunk 생성] 디버그: result 객체 내용: {result}")
2153
  return None
2154
 
2155
  if not result.get('response'):
2156
+ print(f"[Parent Chunk 생성] 오류: Gemini API 응답이 비어있습니다.")
2157
  print(f"[Parent Chunk 생성] 디버그: result 객체 내용: {result}")
2158
  return None
2159
 
 
2199
  error_detail = ollama_response.text if ollama_response.text else '상세 정보 없음'
2200
  if ollama_response.status_code == 404:
2201
  error_msg = f'Ollama API 오류 404: 모델 "{model_name}"을(를) 찾을 수 없습니다. 모델이 Ollama에 설치되어 있는지 확인하세요.'
2202
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
2203
  print(f"[Parent Chunk 생성] 디버그: 만약 Gemini 모델을 사용하려면 모델명이 'gemini:' 또는 'gemini-'로 시작해야 합니다.")
2204
  else:
2205
  error_msg = f'Ollama API 오류: {ollama_response.status_code} - {error_detail[:200]}'
2206
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
2207
  return None
2208
 
2209
  response_data = ollama_response.json()
 
2221
  task_type='parent_chunk'
2222
  )
2223
  except requests.exceptions.Timeout:
2224
+ print(f"[Parent Chunk 생성] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
2225
  print(f"[Parent Chunk 생성] 파일이 너무 크거나 모델 응답이 느릴 수 있습니다.")
2226
  return None
2227
  except requests.exceptions.ConnectionError:
2228
+ print(f"[Parent Chunk 생성] Ollama 연결 오류: Ollama 서버에 연결할 수 없습니다.")
2229
  print(f"[Parent Chunk 생성] 디버그: Ollama URL: {OLLAMA_BASE_URL}")
2230
  return None
2231
  except requests.exceptions.RequestException as e:
2232
+ print(f"[Parent Chunk 생성] Ollama API 오류: {str(e)}")
2233
  print(f"[Parent Chunk 생성] 디버그: Ollama URL: {OLLAMA_BASE_URL}")
2234
  return None
2235
 
 
2331
  db.session.add(parent_chunk)
2332
  db.session.commit()
2333
 
2334
+ print(f"[Parent Chunk 생성] 완료: Parent Chunk가 생성되었습니다.")
2335
  print(f"[Parent Chunk 생성] - 세계관: {len(world_view)}자")
2336
  print(f"[Parent Chunk 생성] - 캐릭터: {len(characters)}자")
2337
  print(f"[Parent Chunk 생성] - 스토리: {len(story)}자")
 
2342
 
2343
  except requests.exceptions.RequestException as e:
2344
  error_msg = f'Ollama API 연결 오류: {str(e)}'
2345
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
2346
  import traceback
2347
  traceback.print_exc()
2348
  return None
2349
  except Exception as e:
2350
  db.session.rollback()
2351
  error_msg = f'Parent Chunk 생성 중 오류: {str(e)}'
2352
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
2353
  import traceback
2354
  traceback.print_exc()
2355
  return None
 
12522
  # 파일 경로 확인
12523
  if not file.file_path or not os.path.exists(file.file_path):
12524
  error_msg = f'파일 경로가 유효하지 않습니다: {file.file_path}'
12525
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
12526
  return jsonify({'error': error_msg}), 500
12527
 
12528
  # 파일 내용 읽기
 
12536
  content = f.read()
12537
  except FileNotFoundError:
12538
  error_msg = f'파일을 찾을 수 없습니다: {file.file_path}'
12539
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
12540
  return jsonify({'error': error_msg}), 500
12541
  except PermissionError:
12542
  error_msg = f'파일 읽기 권한이 없습니다: {file.file_path}'
12543
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
12544
  return jsonify({'error': error_msg}), 500
12545
  except Exception as e:
12546
  error_msg = f'파일을 읽을 수 없습니다: {str(e)}'
12547
+ print(f"[Parent Chunk 생성] 오류: {error_msg}")
12548
  import traceback
12549
  traceback.print_exc()
12550
  return jsonify({'error': error_msg}), 500
 
12578
  import traceback
12579
  error_traceback = traceback.format_exc()
12580
  error_msg = str(e)
12581
+ print(f"[Parent Chunk 생성] 예외 발생: {error_msg}")
12582
  print(f"[Parent Chunk 생성] Traceback:\n{error_traceback}")
12583
  return jsonify({
12584
  'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {error_msg}',
app/services/memory_service.py CHANGED
@@ -1,32 +1,175 @@
1
  import asyncio
2
  from typing import List, Dict, Any, Optional
 
 
 
3
 
4
  class ZepService:
5
- """Zep (Long-term Memory) integration."""
 
 
 
 
6
  def __init__(self, api_key: str, api_url: str):
7
  self.api_key = api_key
8
  self.api_url = api_url
9
 
10
  async def get_session_context(self, project_id: str, session_id: str) -> str:
11
- """Fetch session history and summaries from Zep."""
12
- zep_session_id = f"{project_id}_{session_id}"
13
- # TODO: Implement actual Zep client call
14
- await asyncio.sleep(0.1) # Simulate I/O
15
- return f"Summary of project {project_id} from Zep..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  class Mem0Service:
18
- """Mem0 (Entity/Fact Memory) integration."""
 
 
 
 
19
  def __init__(self, api_key: str):
20
  self.api_key = api_key
21
 
22
  async def get_facts(self, project_id: str) -> List[str]:
23
- """Fetch character and setting facts filtered by project_id."""
24
- # TODO: Implement actual Mem0 client call with metadata filter
25
- await asyncio.sleep(0.1) # Simulate I/O
26
- return [f"Character info for project {project_id} from Mem0"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  async def add_fact(self, project_id: str, fact: str):
29
- """Store a new fact into Mem0."""
30
- # TODO: Implement storage
31
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
 
1
  import asyncio
2
  from typing import List, Dict, Any, Optional
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
 
7
  class ZepService:
8
+ """Zep (Long-term Memory) integration - Narrative Flow only.
9
+
10
+ Zep는 Narrative Flow(서사적 전개)만 저장합니다.
11
+ 정적 세계 설정(Worldview, History, Locations)은 Mem0 또는 RAG를 통해 처리합니다.
12
+ """
13
  def __init__(self, api_key: str, api_url: str):
14
  self.api_key = api_key
15
  self.api_url = api_url
16
 
17
  async def get_session_context(self, project_id: str, session_id: str) -> str:
18
+ """Zep에서 세션 컨텍스트(Narrative Flow)를 가져옵니다.
19
+
20
+ 정적 세계 설정을 제외하고 Narrative Flow만 반환합니다.
21
+ 예: "주인공이 Sector-95에 들어가서 Mika를 만났다"
22
+ """
23
+ from app.database import NovelProject, ChatSession, ChatMessage
24
+
25
+ project = NovelProject.query.filter_by(project_id=project_id).first()
26
+ if not project:
27
+ return "프로젝트를 찾을 수 없습니다."
28
+
29
+ session = ChatSession.query.filter_by(novel_project_id=project.id).first()
30
+ if not session:
31
+ return "아직 기록된 집필 내역이 없습니다."
32
+
33
+ # 최신 대화 내역에서 Narrative Flow만 추출
34
+ # 초기 분석 메시지(model_name="initial-analysis")는 제외
35
+ messages = ChatMessage.query.filter(
36
+ ChatMessage.session_id == session.id,
37
+ ChatMessage.role == 'ai',
38
+ ChatMessage.model_name != 'initial-analysis'
39
+ ).order_by(ChatMessage.created_at.desc()).limit(10).all()
40
+
41
+ if not messages:
42
+ return "집필이 시작되면 여기에 실시간 전개 요약이 표시됩니다."
43
+
44
+ # Narrative Flow 요약 생성 (정적 설정 설명 제외)
45
+ summary_parts = []
46
+ for msg in reversed(messages):
47
+ # 본문에서 서사적 전개만 추출 (간단한 휴리스틱)
48
+ content = msg.content.replace('\n', ' ').strip()
49
+ # 너무 짧거나 정적 설정처럼 보이는 내용은 제외
50
+ if len(content) > 30 and not any(keyword in content.lower() for keyword in ['설정', '세계관', '역사', '배경']):
51
+ summary_parts.append(content[:150]) # 최대 150자
52
+
53
+ if not summary_parts:
54
+ return "최근 서사적 전개가 기록되지 않았습니다."
55
+
56
+ summary = "최근 서사 전개:\n" + "\n".join(f"- {part}" for part in summary_parts[-5:]) # 최근 5개만
57
+
58
+ # 잘림 감지: summary가 "..."로 끝나면 경고
59
+ if summary.rstrip().endswith("...") or len(summary) > 2000:
60
+ logger.warning(
61
+ f"[Zep Memory Truncation Warning] Project {project_id}: "
62
+ f"Summary may be truncated. Length: {len(summary)}, "
63
+ f"Ends with '...': {summary.rstrip().endswith('...')}"
64
+ )
65
+
66
+ return summary
67
 
68
  class Mem0Service:
69
+ """Mem0 (Entity/Fact Memory) integration.
70
+
71
+ 정적 세계 설정(Worldview, History, Locations)을 저장합니다.
72
+ Narrative Flow는 Zep에서 처리하므로 여기에는 포함하지 않습니다.
73
+ """
74
  def __init__(self, api_key: str):
75
  self.api_key = api_key
76
 
77
  async def get_facts(self, project_id: str) -> List[str]:
78
+ """프로젝트와 관련된 정적 설정 정보를 Mem0/RAG에서 가져옵니다.
79
+
80
+ 정적 세계 설정(캐릭터, 장소, 세계관, 역사 등)을 반환합니다.
81
+ Narrative Flow는 Zep에서 처리하므로 여기에는 포함하지 않습니다.
82
+ """
83
+ from app.database import NovelProject, GraphEntity, ParentChunk, UploadedFile, db
84
+
85
+ project = NovelProject.query.filter_by(project_id=project_id).first()
86
+ if not project:
87
+ return []
88
+
89
+ facts = []
90
+
91
+ # 1. 프로젝트의 Mem0에 저장된 정적 설정 사실 가져오기 (NovelProjectFact 테이블)
92
+ try:
93
+ from app.database import NovelProjectFact
94
+ project_facts = NovelProjectFact.query.filter_by(project_id=project.id).all()
95
+ for fact in project_facts:
96
+ facts.append(fact.fact_content)
97
+ except Exception as e:
98
+ # 테이블이 아직 생성되지 않은 경우 무시 (마이그레이션 필요)
99
+ logger.debug(f"[Mem0] NovelProjectFact table not available: {e}")
100
+
101
+ # 2. 참고 자료(RAG)가 있는 경우 해당 파일의 분석 정보 가져오기
102
+ if project.reference_source_id:
103
+ file = UploadedFile.query.filter(
104
+ (UploadedFile.id == project.reference_source_id) |
105
+ (UploadedFile.filename.contains(project.reference_source_id))
106
+ ).first()
107
+
108
+ if file:
109
+ # ParentChunk에서 세계관 정보 가져오기
110
+ parent = ParentChunk.query.filter_by(file_id=file.id).first()
111
+ if parent:
112
+ if parent.characters:
113
+ facts.append(f"[주요 캐릭터 설정] {parent.characters}")
114
+ if parent.world_setting:
115
+ facts.append(f"[세계관 설정] {parent.world_setting}")
116
+
117
+ # GraphEntity에서 인물 및 장소 정보 가져오기 (정적 설정)
118
+ entities = GraphEntity.query.filter_by(file_id=file.id).limit(20).all()
119
+ for ent in entities:
120
+ if ent.entity_type == 'character':
121
+ fact_str = f"{ent.entity_name} ({ent.role or '인물'}): {ent.description or ''}"
122
+ if ent.role:
123
+ fact_str += f" - 역할: {ent.role}"
124
+ facts.append(fact_str)
125
+ elif ent.entity_type == 'location':
126
+ fact_str = f"{ent.entity_name} (장소): {ent.description or ''}"
127
+ if ent.category:
128
+ fact_str += f" - 유형: {ent.category}"
129
+ facts.append(fact_str)
130
+
131
+ # 3. facts가 없으면 안내 메시지
132
+ if not facts:
133
+ return ["아직 추출된 핵심 설정이 없습니다. 초기 설정을 저장하면 여기에 표시됩니다."]
134
+
135
+ return facts
136
 
137
  async def add_fact(self, project_id: str, fact: str):
138
+ """정적 세계 설정 사실을 Mem0에 저장합니다.
139
+
140
+ Narrative Flow가 아닌 정적 설정(캐릭터 설명, 장소 설명, 세계관 등)을 저장합니다.
141
+ """
142
+ if not fact or not fact.strip():
143
+ return
144
+
145
+ from app.database import NovelProject, db
146
+
147
+ project = NovelProject.query.filter_by(project_id=project_id).first()
148
+ if not project:
149
+ logger.warning(f"[Mem0] Project {project_id} not found, cannot save fact")
150
+ return
151
+
152
+ try:
153
+ from app.database import NovelProjectFact
154
+
155
+ # 중복 확인: 같은 내용의 fact가 이미 있으면 추가하지 않음
156
+ existing = NovelProjectFact.query.filter_by(
157
+ project_id=project.id,
158
+ fact_content=fact.strip()
159
+ ).first()
160
+
161
+ if existing:
162
+ return # 이미 존재하는 fact는 건너뛰기
163
+
164
+ new_fact = NovelProjectFact(
165
+ project_id=project.id,
166
+ fact_content=fact.strip()
167
+ )
168
+ db.session.add(new_fact)
169
+ db.session.commit()
170
+ logger.info(f"[Mem0] Saved fact for project {project_id}: {fact[:50]}...")
171
+ except Exception as e:
172
+ db.session.rollback()
173
+ # 테이블이 아직 생성되지 않은 경우 경고만 출력 (마이그레이션 필요)
174
+ logger.warning(f"[Mem0] Failed to save fact for project {project_id} (table may not exist): {e}")
175
 
app/utils/text_utils.py CHANGED
@@ -194,4 +194,27 @@ def extract_chapter_number(text: str) -> Optional[int]:
194
  return None
195
 
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
 
194
  return None
195
 
196
 
197
+ def clean_system_comments(text: str) -> str:
198
+ """
199
+ Markdown 주석 형태의 시스템 메시지 제거 ([//]: # (...))
200
+
201
+ Args:
202
+ text: AI 에이전트의 응답 텍스트
203
+
204
+ Returns:
205
+ 주석이 제거된 텍스트
206
+ """
207
+ if not text:
208
+ return ''
209
+
210
+ # [//]: # (...) 형태의 행 제거 (멀티라인 고려)
211
+ # ^[ \t]*\[\/\/\]: # \(.*?\) 형태 매칭
212
+ cleaned = re.sub(r'^[ \t]*\[\/\/\]: # \(.*?\)\s*\n?', '', text, flags=re.MULTILINE)
213
+
214
+ # 혹시 남을 수 있는 인라인 주석도 제거
215
+ cleaned = re.sub(r'\[\/\/\]: # \(.*?\)', '', cleaned).strip()
216
+
217
+ return cleaned
218
+
219
+
220
 
force_update_menu.py CHANGED
@@ -43,6 +43,15 @@ if __name__ == "__main__":
43
 
44
 
45
 
 
 
 
 
 
 
 
 
 
46
 
47
 
48
 
 
43
 
44
 
45
 
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
 
56
 
57
 
migrate_add_viewer_fields.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NovelProject 모델에 뷰어 관련 필드 추가 마이그레이션
3
+ """
4
+ import sys
5
+ import os
6
+
7
+ # 프로젝트 루트를 Python 경로에 추가
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from app import create_app
11
+ from app.database import db
12
+ from sqlalchemy import inspect, text
13
+
14
+ def migrate_add_viewer_fields():
15
+ """NovelProject 테이블에 뷰어 관련 필드 추가"""
16
+ app = create_app()
17
+
18
+ with app.app_context():
19
+ try:
20
+ inspector = inspect(db.engine)
21
+ columns = [c['name'] for c in inspector.get_columns('novel_project')]
22
+
23
+ # viewer_enabled 필드 추가
24
+ if 'viewer_enabled' not in columns:
25
+ print("Adding 'viewer_enabled' column to novel_project table...")
26
+ with db.engine.connect() as conn:
27
+ conn.execute(text("ALTER TABLE novel_project ADD COLUMN viewer_enabled BOOLEAN DEFAULT 0 NOT NULL"))
28
+ conn.commit()
29
+ print("✓ 'viewer_enabled' column added successfully")
30
+ else:
31
+ print("'viewer_enabled' column already exists")
32
+
33
+ # viewer_selected_message_id 필드 추가
34
+ if 'viewer_selected_message_id' not in columns:
35
+ print("Adding 'viewer_selected_message_id' column to novel_project table...")
36
+ with db.engine.connect() as conn:
37
+ conn.execute(text("ALTER TABLE novel_project ADD COLUMN viewer_selected_message_id INTEGER REFERENCES chat_message(id)"))
38
+ conn.commit()
39
+ print("✓ 'viewer_selected_message_id' column added successfully")
40
+ else:
41
+ print("'viewer_selected_message_id' column already exists")
42
+
43
+ print("\n마이그레이션이 완료되었습니다!")
44
+
45
+ except Exception as e:
46
+ print(f"마이그레이션 중 오류 발생: {e}")
47
+ import traceback
48
+ traceback.print_exc()
49
+ raise
50
+
51
+ if __name__ == '__main__':
52
+ migrate_add_viewer_fields()
53
+
migrate_viewer_multiple_messages.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NovelProject 모델의 viewer_selected_message_id를 viewer_selected_message_ids (JSON 배열)로 변경
3
+ """
4
+ import sys
5
+ import os
6
+ import json
7
+
8
+ # 프로젝트 루트를 Python 경로에 추가
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from app import create_app
12
+ from app.database import db
13
+ from sqlalchemy import inspect, text
14
+
15
+ def migrate_viewer_multiple_messages():
16
+ """viewer_selected_message_id를 viewer_selected_message_ids로 변경"""
17
+ app = create_app()
18
+
19
+ with app.app_context():
20
+ try:
21
+ inspector = inspect(db.engine)
22
+ columns = [c['name'] for c in inspector.get_columns('novel_project')]
23
+
24
+ # 기존 viewer_selected_message_id 값 백업 및 변환
25
+ if 'viewer_selected_message_id' in columns:
26
+ print("기존 viewer_selected_message_id 값을 백업하고 변환 중...")
27
+ with db.engine.connect() as conn:
28
+ # 기존 값 가져오기
29
+ result = conn.execute(text("SELECT id, viewer_selected_message_id FROM novel_project WHERE viewer_selected_message_id IS NOT NULL"))
30
+ rows = result.fetchall()
31
+
32
+ # viewer_selected_message_ids 컬럼 추가
33
+ if 'viewer_selected_message_ids' not in columns:
34
+ print("Adding 'viewer_selected_message_ids' column to novel_project table...")
35
+ conn.execute(text("ALTER TABLE novel_project ADD COLUMN viewer_selected_message_ids TEXT"))
36
+ conn.commit()
37
+ print("✓ 'viewer_selected_message_ids' column added successfully")
38
+
39
+ # 기존 단일 값을 JSON 배열로 변환
40
+ for row in rows:
41
+ project_id = row[0]
42
+ old_message_id = row[1]
43
+ if old_message_id:
44
+ # 단일 값을 배열로 변환
45
+ new_ids = json.dumps([old_message_id])
46
+ conn.execute(text(
47
+ "UPDATE novel_project SET viewer_selected_message_ids = :ids WHERE id = :pid"
48
+ ), {"ids": new_ids, "pid": project_id})
49
+
50
+ conn.commit()
51
+ print(f"✓ Converted {len(rows)} projects' viewer message IDs to array format")
52
+
53
+ # 기존 컬럼 삭제 (선택사항 - 필요시 주석 해제)
54
+ # print("Removing old 'viewer_selected_message_id' column...")
55
+ # conn.execute(text("ALTER TABLE novel_project DROP COLUMN viewer_selected_message_id"))
56
+ # conn.commit()
57
+ # print("✓ Old column removed")
58
+ print("\n마이그레이션이 완료되었습니다!")
59
+ else:
60
+ # viewer_selected_message_id가 없으면 새 컬럼만 추가
61
+ if 'viewer_selected_message_ids' not in columns:
62
+ print("Adding 'viewer_selected_message_ids' column to novel_project table...")
63
+ with db.engine.connect() as conn:
64
+ conn.execute(text("ALTER TABLE novel_project ADD COLUMN viewer_selected_message_ids TEXT"))
65
+ conn.commit()
66
+ print("✓ 'viewer_selected_message_ids' column added successfully")
67
+ print("\n마이그레이션이 완료되었습니다!")
68
+ else:
69
+ print("'viewer_selected_message_ids' column already exists")
70
+
71
+ except Exception as e:
72
+ print(f"마이그레이션 중 오류 발생: {e}")
73
+ import traceback
74
+ traceback.print_exc()
75
+ raise
76
+
77
+ if __name__ == '__main__':
78
+ migrate_viewer_multiple_messages()
79
+
reset_menu_to_default.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app import create_app
2
+ from app.database import db, SystemConfig
3
+ import json
4
+ from app.routes import get_default_admin_menu
5
+
6
+ def reset_menu():
7
+ app = create_app()
8
+ with app.app_context():
9
+ default_menu = get_default_admin_menu()
10
+ SystemConfig.set_config('admin_menu_config_v1', json.dumps(default_menu, ensure_ascii=False, indent=2))
11
+ print("Menu configuration has been COMPLETELY RESET to defaults.")
12
+
13
+ if __name__ == "__main__":
14
+ reset_menu()
15
+
run.py CHANGED
@@ -2,11 +2,19 @@ import sys
2
  import os
3
  import logging
4
  from logging.handlers import RotatingFileHandler
 
5
 
6
  # UTF-8 인코딩 강제 설정 (Windows cp949 오류 방지)
7
  if sys.platform == 'win32':
8
  sys.stdout.reconfigure(encoding='utf-8')
9
  sys.stderr.reconfigure(encoding='utf-8')
 
 
 
 
 
 
 
10
 
11
  from app import create_app
12
 
@@ -23,22 +31,28 @@ file_handler.setFormatter(logging.Formatter(
23
  ))
24
  file_handler.setLevel(logging.INFO)
25
 
26
- # 콘솔 핸들러 설정
27
  console_handler = logging.StreamHandler(sys.stdout)
28
  console_handler.setFormatter(logging.Formatter(
29
  '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
30
  ))
31
- console_handler.setLevel(logging.INFO)
32
 
33
  # Flask 앱 로거 설정
34
- app.logger.setLevel(logging.INFO)
35
  app.logger.addHandler(file_handler)
36
  app.logger.addHandler(console_handler)
37
 
38
  # 루트 로거 설정 (모든 로거에 적용)
39
  root_logger = logging.getLogger()
40
- root_logger.setLevel(logging.INFO)
41
  root_logger.addHandler(console_handler)
 
 
 
 
 
 
42
 
43
  # Werkzeug 로거 설정 (HTTP 요청 로깅)
44
  werkzeug_logger = logging.getLogger('werkzeug')
 
2
  import os
3
  import logging
4
  from logging.handlers import RotatingFileHandler
5
+ import asyncio
6
 
7
  # UTF-8 인코딩 강제 설정 (Windows cp949 오류 방지)
8
  if sys.platform == 'win32':
9
  sys.stdout.reconfigure(encoding='utf-8')
10
  sys.stderr.reconfigure(encoding='utf-8')
11
+ # Windows에서 asyncio와 httpx/Flask 간의 'Event loop is closed' 에러 방지
12
+ try:
13
+ import nest_asyncio
14
+ nest_asyncio.apply()
15
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
16
+ except:
17
+ pass
18
 
19
  from app import create_app
20
 
 
31
  ))
32
  file_handler.setLevel(logging.INFO)
33
 
34
+ # 콘솔 핸들러 설정 (터미널에 출력)
35
  console_handler = logging.StreamHandler(sys.stdout)
36
  console_handler.setFormatter(logging.Formatter(
37
  '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
38
  ))
39
+ console_handler.setLevel(logging.DEBUG) # DEBUG 레벨까지 출력하여 모든 로그 확인
40
 
41
  # Flask 앱 로거 설정
42
+ app.logger.setLevel(logging.DEBUG)
43
  app.logger.addHandler(file_handler)
44
  app.logger.addHandler(console_handler)
45
 
46
  # 루트 로거 설정 (모든 로거에 적용)
47
  root_logger = logging.getLogger()
48
+ root_logger.setLevel(logging.DEBUG)
49
  root_logger.addHandler(console_handler)
50
+ # 기존 핸들러 제거 후 새로 추가 (중복 방지)
51
+ for handler in root_logger.handlers[:]:
52
+ if isinstance(handler, logging.StreamHandler) and handler is not console_handler:
53
+ root_logger.removeHandler(handler)
54
+ if console_handler not in root_logger.handlers:
55
+ root_logger.addHandler(console_handler)
56
 
57
  # Werkzeug 로거 설정 (HTTP 요청 로깅)
58
  werkzeug_logger = logging.getLogger('werkzeug')
static/lib/js/babel.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/lib/js/framer-motion.js ADDED
The diff for this file is too large to render. See raw diff
 
static/lib/js/lucide.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/lib/js/react-dom.min.js ADDED
The diff for this file is too large to render. See raw diff
 
static/lib/js/react.min.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license React
3
+ * react.production.min.js
4
+ *
5
+ * Copyright (c) Facebook, Inc. and its affiliates.
6
+ *
7
+ * This source code is licensed under the MIT license found in the
8
+ * LICENSE file in the root directory of this source tree.
9
+ */
10
+ (function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
11
+ e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
12
+ function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
13
+ N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
14
+ ("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
15
+ 0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
16
+ function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
17
+ k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
18
+ ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
19
+ b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
20
+ ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
21
+ void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
22
+ ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
23
+ b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
24
+ k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
25
+ ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
26
+ t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
27
+ Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
28
+ render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
29
+ c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
30
+ c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
31
+ })();
static/lib/js/tailwind.js ADDED
The diff for this file is too large to render. See raw diff
 
templates/admin_webtoon_milestone_producer_manager.html CHANGED
@@ -340,3 +340,12 @@
340
  </body>
341
  </html>
342
 
 
 
 
 
 
 
 
 
 
 
340
  </body>
341
  </html>
342
 
343
+
344
+
345
+
346
+
347
+
348
+
349
+
350
+
351
+
templates/creation_base.html ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}웹소설 창작 - SOYMEDIA{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <!-- Font Awesome -->
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
11
+ <!-- Bootstrap CSS -->
12
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
13
+ <style>
14
+ :root {
15
+ --bg-primary: #ffffff;
16
+ --bg-secondary: #f8f9fa;
17
+ --text-primary: #202124;
18
+ --text-secondary: #5f6368;
19
+ --accent: #1a73e8;
20
+ --border: #dadce0;
21
+ }
22
+ body {
23
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
24
+ background: var(--bg-secondary);
25
+ color: var(--text-primary);
26
+ margin: 0;
27
+ padding: 0;
28
+ }
29
+ /* 기존 관리자 페이지 디자인 통일을 위한 추가 스타일 */
30
+ .admin-nav {
31
+ position: sticky;
32
+ top: 0;
33
+ z-index: 10000; /* 최상단 고정 */
34
+ }
35
+ /* Bootstrap 드롭다운과 기존 메뉴 스타일 충돌 해결 */
36
+ .admin-nav .dropdown-menu {
37
+ display: block !important;
38
+ opacity: 0 !important;
39
+ visibility: hidden !important;
40
+ transition: all 0.2s ease !important;
41
+ pointer-events: none !important;
42
+ margin: 0 !important;
43
+ border: 1px solid #dadce0 !important;
44
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
45
+ transform: translateY(-8px) !important;
46
+ position: absolute !important;
47
+ z-index: 10002 !important;
48
+ background: white !important;
49
+ }
50
+ .admin-nav .dropdown:hover .dropdown-menu {
51
+ opacity: 1 !important;
52
+ visibility: visible !important;
53
+ transform: translateY(0) !important;
54
+ pointer-events: auto !important;
55
+ }
56
+ .admin-nav .dropdown-toggle::after {
57
+ display: inline-block !important;
58
+ margin-left: .255em !important;
59
+ vertical-align: .255em !important;
60
+ content: "▼" !important; /* Bootstrap 기본 아이콘 제거 및 통일 */
61
+ border: none !important;
62
+ font-size: 10px !important;
63
+ }
64
+ /* 네비게이션 항목 간격 및 폰트 크기 조정 */
65
+ .admin-nav .dropdown-toggle, .admin-nav .btn {
66
+ font-family: inherit;
67
+ font-size: 14px;
68
+ font-weight: 500;
69
+ }
70
+ </style>
71
+ {% block extra_css %}{% endblock %}
72
+ </head>
73
+ <body>
74
+ {% set admin_nav_title = '창작 플랫폼' %}
75
+ {% set admin_nav_icon = '✍️' %}
76
+ {% include '_admin_nav.html' %}
77
+
78
+ {% block content %}{% endblock %}
79
+
80
+ <!-- Bootstrap Bundle JS -->
81
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
82
+ {% block extra_js %}{% endblock %}
83
+ </body>
84
+ </html>
85
+
templates/novel_analysis.html ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}{{ project.title }} - 분석 확인 - SOYMEDIA{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <style>
7
+ body {
8
+ background-color: #f4f7f6;
9
+ }
10
+ .analysis-container {
11
+ max-width: 1200px;
12
+ margin: 2rem auto;
13
+ padding: 0 1rem;
14
+ }
15
+ .stats-card {
16
+ background: white;
17
+ border-radius: 16px;
18
+ box-shadow: 0 4px 20px rgba(0,0,0,0.05);
19
+ padding: 1.5rem;
20
+ height: 100%;
21
+ border: 1px solid rgba(0,0,0,0.05);
22
+ }
23
+ .section-title {
24
+ font-weight: 800;
25
+ color: #2d3748;
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 10px;
29
+ margin-bottom: 1.5rem;
30
+ padding-bottom: 0.75rem;
31
+ border-bottom: 2px solid #edf2f7;
32
+ }
33
+ .fact-tag {
34
+ background: #ebf8ff;
35
+ color: #2b6cb0;
36
+ padding: 0.5rem 1rem;
37
+ border-radius: 10px;
38
+ font-size: 0.9rem;
39
+ margin-bottom: 0.75rem;
40
+ border-left: 4px solid #3182ce;
41
+ }
42
+ .context-box {
43
+ background: #fffaf0;
44
+ border: 1px solid #feebc8;
45
+ padding: 1.25rem;
46
+ border-radius: 12px;
47
+ color: #744210;
48
+ line-height: 1.7;
49
+ font-family: 'Noto Serif KR', serif;
50
+ }
51
+ .badge-mode {
52
+ font-size: 0.8rem;
53
+ padding: 0.4rem 0.8rem;
54
+ border-radius: 20px;
55
+ }
56
+ .nav-tabs .nav-link {
57
+ border: none;
58
+ color: #718096;
59
+ font-weight: 600;
60
+ padding: 1rem 1.5rem;
61
+ }
62
+ .nav-tabs .nav-link.active {
63
+ color: #3182ce;
64
+ border-bottom: 3px solid #3182ce;
65
+ background: transparent;
66
+ }
67
+ </style>
68
+ {% endblock %}
69
+
70
+ {% block content %}
71
+ <div class="analysis-container">
72
+ <div class="d-flex justify-content-between align-items-center mb-4">
73
+ <div>
74
+ <nav aria-label="breadcrumb">
75
+ <ol class="breadcrumb mb-1">
76
+ <li class="breadcrumb-item"><a href="/creation/webnovel">대시보드</a></li>
77
+ <li class="breadcrumb-item active" aria-current="page">분석</li>
78
+ </ol>
79
+ </nav>
80
+ <h2 class="fw-bold mb-0">
81
+ <i class="fas fa-microscope text-info me-2"></i>{{ project.title }}
82
+ <span class="badge badge-mode {{ 'bg-success-subtle text-success' if project.mode == 'CREATIVE' else 'bg-info-subtle text-info' }} ms-2">
83
+ {{ project.mode }}
84
+ </span>
85
+ </h2>
86
+ </div>
87
+ <div class="d-flex align-items-center gap-3">
88
+ <div class="d-flex align-items-center gap-2 bg-white px-3 py-2 rounded-pill shadow-sm border">
89
+ <label for="modelSelect" class="small fw-bold text-muted mb-0"><i class="fas fa-robot me-1"></i>분석 모델:</label>
90
+ <select id="modelSelect" class="form-select form-select-sm border-0 bg-transparent shadow-none" style="width: 180px; font-size: 0.85rem; font-weight: 600;">
91
+ <option value="">모델 로딩 중...</option>
92
+ </select>
93
+ <button class="btn btn-sm text-muted p-0 hover-rotate" id="refreshModels" title="모델 새로고침">
94
+ <i class="fas fa-sync-alt"></i>
95
+ </button>
96
+ </div>
97
+ <a href="/creation/workspace/{{ project.project_id }}" class="btn btn-primary shadow-sm rounded-pill px-4">
98
+ <i class="fas fa-pen-nib me-1"></i> 집필 계속하기
99
+ </a>
100
+ </div>
101
+ </div>
102
+
103
+ <ul class="nav nav-tabs mb-4" id="analysisTab" role="tablist">
104
+ <li class="nav-item" role="presentation">
105
+ <button class="nav-link active" id="init-tab" data-bs-toggle="tab" data-bs-target="#init" type="button" role="tab">초기 설정</button>
106
+ </li>
107
+ <li class="nav-item" role="presentation">
108
+ <button class="nav-link" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summary" type="button" role="tab">전체 요약</button>
109
+ </li>
110
+ <li class="nav-item" role="presentation">
111
+ <button class="nav-link" id="facts-tab" data-bs-toggle="tab" data-bs-target="#facts" type="button" role="tab">캐릭터/설정 (Facts)</button>
112
+ </li>
113
+ <li class="nav-item" role="presentation">
114
+ <button class="nav-link" id="context-tab" data-bs-toggle="tab" data-bs-target="#context" type="button" role="tab">스토리 문맥 (Zep)</button>
115
+ </li>
116
+ <li class="nav-item" role="presentation">
117
+ <button class="nav-link" id="writer-prompt-tab" data-bs-toggle="tab" data-bs-target="#writer-prompt" type="button" role="tab">작가 지침 (Prompt)</button>
118
+ </li>
119
+ <li class="nav-item" role="presentation">
120
+ <button class="nav-link" id="prompt-tab" data-bs-toggle="tab" data-bs-target="#prompt" type="button" role="tab">질문 조건 확인</button>
121
+ </li>
122
+ </ul>
123
+
124
+ <div class="tab-content" id="analysisTabContent">
125
+ <!-- 초기 ���정 -->
126
+ <div class="tab-pane fade show active" id="init" role="tabpanel">
127
+ <div class="stats-card">
128
+ <div class="mb-3 border-bottom pb-2">
129
+ <h5 class="section-title mb-0 border-0 pb-0"><i class="fas fa-cog text-primary"></i> 세계관 및 프로젝트 초기 설정</h5>
130
+ </div>
131
+ <p class="text-muted small mb-3">작품의 배경, 핵심 로그라인, 주요 캐릭터의 기본 설정을 입력해주세요. AI가 이를 분석하여 기본 지식으로 활용합니다.</p>
132
+ <form id="initialSettingsForm">
133
+ <textarea id="initialSettings" class="form-control mb-3" rows="12" placeholder="예: 2153년 네오-서울, 지상은 방사능으로 덮였고 인류는 하층부 언더쉘에서 거주한다. 주인공 미카는 기억을 잃은 스크래퍼로... ">{{ project.initial_settings or '' }}</textarea>
134
+
135
+ <!-- 분석 단계 표시 -->
136
+ <div id="analysisProgress" class="mb-3" style="display: none;">
137
+ <div class="p-3 bg-light rounded-3 border">
138
+ <h6 class="fw-bold mb-3 small text-uppercase"><i class="fas fa-tasks me-2"></i>분석 진행 상황</h6>
139
+ <div class="d-flex flex-column gap-2">
140
+ <div id="step-parent" class="d-flex align-items-center gap-2 text-muted small">
141
+ <i class="fas fa-circle-notch fa-spin step-icon" style="display: none;"></i>
142
+ <i class="fas fa-check-circle text-success step-check" style="display: none;"></i>
143
+ <i class="far fa-circle step-pending"></i>
144
+ <span>단계 1: 전체 요약 생성 (Parent Chunk)</span>
145
+ </div>
146
+ <div id="step-chunks" class="d-flex align-items-center gap-2 text-muted small">
147
+ <i class="fas fa-circle-notch fa-spin step-icon" style="display: none;"></i>
148
+ <i class="fas fa-check-circle text-success step-check" style="display: none;"></i>
149
+ <i class="far fa-circle step-pending"></i>
150
+ <span>단계 2: 지식 베이스 구축 (Chunks)</span>
151
+ </div>
152
+ <div id="step-graph" class="d-flex align-items-center gap-2 text-muted small">
153
+ <i class="fas fa-circle-notch fa-spin step-icon" style="display: none;"></i>
154
+ <i class="fas fa-check-circle text-success step-check" style="display: none;"></i>
155
+ <i class="far fa-circle step-pending"></i>
156
+ <span>단계 3: 지식 그래프 추출 (GraphRAG)</span>
157
+ </div>
158
+ <div id="step-final" class="d-flex align-items-center gap-2 text-muted small">
159
+ <i class="fas fa-circle-notch fa-spin step-icon" style="display: none;"></i>
160
+ <i class="fas fa-check-circle text-success step-check" style="display: none;"></i>
161
+ <i class="far fa-circle step-pending"></i>
162
+ <span>단계 4: 캐릭터/설정 사실 추출 (Mem0)</span>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <div class="d-flex justify-content-end">
169
+ <button type="button" class="btn btn-success" id="saveInitialSettings">
170
+ <i class="fas fa-save me-1"></i> 저장 및 분석 시작
171
+ </button>
172
+ </div>
173
+ </form>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- 전체 요약 -->
178
+ <div class="tab-pane fade" id="summary" role="tabpanel">
179
+ <div class="row g-4">
180
+ <div class="col-md-8">
181
+ {% if parent_chunk %}
182
+ <div class="stats-card mb-4">
183
+ <h5 class="section-title"><i class="fas fa-atlas text-primary"></i> Parent Chunk 분석 (세계관/스토리)</h5>
184
+ <div class="mb-4">
185
+ <h6 class="fw-bold text-primary small"><i class="fas fa-globe-asia me-1"></i> 세계관 및 배경</h6>
186
+ <div class="p-3 bg-light rounded-3 border-start border-primary border-4">
187
+ {{ parent_chunk.world_view | replace('\n', '<br>') | safe }}
188
+ </div>
189
+ </div>
190
+ <div class="mb-4">
191
+ <h6 class="fw-bold text-success small"><i class="fas fa-book-open me-1"></i> 전체 줄거리</h6>
192
+ <div class="p-3 bg-light rounded-3 border-start border-success border-4">
193
+ {{ parent_chunk.story | replace('\n', '<br>') | safe }}
194
+ </div>
195
+ </div>
196
+ {% if parent_chunk.episodes %}
197
+ <div class="mb-0">
198
+ <h6 class="fw-bold text-info small"><i class="fas fa-list-ol me-1"></i> 주요 에피소드 구조</h6>
199
+ <div class="p-3 bg-light rounded-3 border-start border-info border-4">
200
+ {{ parent_chunk.episodes | replace('\n', '<br>') | safe }}
201
+ </div>
202
+ </div>
203
+ {% endif %}
204
+ </div>
205
+ {% endif %}
206
+
207
+ <div class="stats-card">
208
+ <h5 class="section-title"><i class="fas fa-align-left text-primary"></i> 현재 집필 문맥 요약 (Zep)</h5>
209
+ <div class="context-box whitespace-pre-wrap">
210
+ {{ context | default("아직 충분한 대화가 이루어지지 않아 요약된 정보가 없습니다.") }}
211
+ </div>
212
+ </div>
213
+ </div>
214
+ <div class="col-md-4">
215
+ <div class="stats-card">
216
+ <h5 class="section-title"><i class="fas fa-database text-warning"></i> 데이터 통계</h5>
217
+ <div class="d-flex flex-column gap-3">
218
+ <div class="p-3 bg-light rounded-3">
219
+ <div class="small text-muted">수집된 설정(Facts)</div>
220
+ <div class="h3 fw-bold mb-0 text-warning">{{ facts|length }}</div>
221
+ </div>
222
+ <div class="p-3 bg-light rounded-3">
223
+ <div class="small text-muted">추출된 엔티티(Graph)</div>
224
+ <div class="h3 fw-bold mb-0 text-primary">{{ graph_entities|length }}</div>
225
+ </div>
226
+ <div class="p-3 bg-light rounded-3">
227
+ <div class="small text-muted">파악된 관계(Graph)</div>
228
+ <div class="h3 fw-bold mb-0 text-success">{{ graph_relationships|length }}</div>
229
+ </div>
230
+ <div class="p-3 bg-light rounded-3">
231
+ <div class="small text-muted">창작 모드</div>
232
+ <div class="fw-bold">{{ project.mode }}</div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- 캐릭터/설정 -->
241
+ <div class="tab-pane fade" id="facts" role="tabpanel">
242
+ <div class="row g-4">
243
+ <div class="col-md-7">
244
+ <!-- GraphRAG 엔티티 정보 -->
245
+ <div class="stats-card mb-4">
246
+ <h5 class="section-title"><i class="fas fa-project-diagram text-primary"></i> GraphRAG: 주요 엔티티 및 설정</h5>
247
+ {% if graph_entities %}
248
+ <div class="d-flex flex-column gap-3">
249
+ {% for entity in graph_entities %}
250
+ <div class="p-3 bg-white border rounded-3 shadow-sm">
251
+ <div class="d-flex justify-content-between align-items-center mb-2">
252
+ <h6 class="fw-bold mb-0 text-dark">{{ entity.name }}</h6>
253
+ <span class="badge {{ 'bg-primary' if entity.entity_type == 'CHARACTER' else 'bg-success' if entity.entity_type == 'PLACE' else 'bg-info' }} rounded-pill small">
254
+ {{ entity.entity_type }}
255
+ </span>
256
+ </div>
257
+ <p class="small text-muted mb-0">{{ entity.description }}</p>
258
+ </div>
259
+ {% endfor %}
260
+ </div>
261
+ {% else %}
262
+ <p class="text-center py-4 text-muted small">추출된 엔티티 정보가 없습니다. 분석을 시작해주세요.</p>
263
+ {% endif %}
264
+ </div>
265
+
266
+ <!-- GraphRAG 관계 정보 -->
267
+ {% if graph_relationships %}
268
+ <div class="stats-card">
269
+ <h5 class="section-title"><i class="fas fa-link text-success"></i> 인물 및 공간 관계도</h5>
270
+ <div class="list-group list-group-flush border rounded-3 overflow-hidden">
271
+ {% for rel in graph_relationships %}
272
+ <div class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3">
273
+ <div class="fw-bold text-primary">{{ rel.source }}</div>
274
+ <div class="text-muted small"><i class="fas fa-arrow-right"></i></div>
275
+ <div class="px-2 py-1 bg-light rounded small border">{{ rel.relationship_type }}</div>
276
+ <div class="text-muted small"><i class="fas fa-arrow-right"></i></div>
277
+ <div class="fw-bold text-success">{{ rel.target }}</div>
278
+ </div>
279
+ {% endfor %}
280
+ </div>
281
+ </div>
282
+ {% endif %}
283
+ </div>
284
+
285
+ <div class="col-md-5">
286
+ <!-- Mem0 Facts -->
287
+ <div class="stats-card">
288
+ <h5 class="section-title"><i class="fas fa-id-card text-warning"></i> Mem0: 개별 사실(Facts) 추출</h5>
289
+ <div class="d-flex flex-column gap-2">
290
+ {% if facts %}
291
+ {% for fact in facts %}
292
+ <div class="fact-tag mb-0">
293
+ <i class="fas fa-check-circle me-2 opacity-50"></i>{{ fact }}
294
+ </div>
295
+ {% endfor %}
296
+ {% else %}
297
+ <div class="text-center py-5 text-muted">
298
+ <i class="fas fa-comment-slash fa-2x mb-2 opacity-25"></i>
299
+ <p class="small">에이전트와의 대화가 쌓이면 자동으로 추가 설정이 추출됩니다.</p>
300
+ </div>
301
+ {% endif %}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <!-- 스토리 문맥 -->
309
+ <div class="tab-pane fade" id="context" role="tabpanel">
310
+ <div class="stats-card">
311
+ <h5 class="section-title"><i class="fas fa-brain text-info"></i> 에이전트 인지 문맥 (Long-term)</h5>
312
+ <div class="p-4 bg-dark text-success rounded-3 font-monospace" style="min-height: 200px; font-size: 0.9rem;">
313
+ <div class="mb-2">// Zep Memory Retrieval Stream...</div>
314
+ {{ context }}
315
+ </div>
316
+ <div class="mt-3 text-muted small">
317
+ <i class="fas fa-info-circle me-1"></i> 이 데이터는 AI 에이전트가 다음 문장을 생성할 때 참고하는 핵심 기억 장치입니다.
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- 작가 지침 (Prompt) -->
323
+ <div class="tab-pane fade" id="writer-prompt" role="tabpanel">
324
+ <div class="stats-card">
325
+ <div class="mb-3 border-bottom pb-2">
326
+ <h5 class="section-title mb-0 border-0 pb-0"><i class="fas fa-pen-fancy text-primary"></i> 작가 전용 시스템 지침 (Custom Prompt)</h5>
327
+ </div>
328
+ <p class="text-muted small mb-3">
329
+ 에이전트에게 소설 작성 시 반드시 지켜야 할 **스타일, 문체, 주의사항** 등을 입력하세요.
330
+ 이 지침은 다른 설정들보다 우선적으로 고려됩니다.
331
+ </p>
332
+ <form id="customPromptForm">
333
+ <textarea id="customSystemPrompt" class="form-control mb-3" rows="12" placeholder="예:
334
+ - 문장은 간결하게 작성하고 대화 위주로 전개해줘.
335
+ - 주인공의 심리 묘사는 1인칭 주인공 시점으로 항상 유지해줘.
336
+ - 고유 명사가 나올 때는 뒤에 괄호로 간단한 설명을 붙여줘.">{{ project.custom_system_prompt or '' }}</textarea>
337
+
338
+ <div class="d-flex justify-content-between align-items-center">
339
+ <div class="text-muted small">
340
+ <i class="fas fa-info-circle me-1"></i> 저장 시 즉시 에이전트의 답변 방식에 반영됩니다.
341
+ </div>
342
+ <button type="button" class="btn btn-primary shadow-sm" id="saveCustomPrompt">
343
+ <i class="fas fa-check-circle me-1"></i> 지침 저장하기
344
+ </button>
345
+ </div>
346
+ </form>
347
+ </div>
348
+ </div>
349
+
350
+ <!-- 질문 조건 확인 -->
351
+ <div class="tab-pane fade" id="prompt" role="tabpanel">
352
+ <div class="stats-card">
353
+ <div class="d-flex justify-content-between align-items-center mb-3">
354
+ <h5 class="section-title mb-0"><i class="fas fa-code text-primary"></i> 에이전트에게 전달되는 시스템 프롬프트</h5>
355
+ <button class="btn btn-sm btn-outline-primary" id="refreshPrompt">
356
+ <i class="fas fa-sync-alt me-1"></i> 새로고침
357
+ </button>
358
+ </div>
359
+ <div class="mb-3">
360
+ <p class="text-muted small mb-2">
361
+ <i class="fas fa-info-circle me-1"></i>
362
+ 이 내용은 workspace에서 질문할 때 AI 에이전트에게 전달되는 모든 조건과 컨텍스트입니다.
363
+ </p>
364
+ </div>
365
+ <div id="promptContent" class="p-4 bg-light rounded-3 border" style="min-height: 300px; max-height: 600px; overflow-y: auto;">
366
+ <div class="text-center text-muted py-5">
367
+ <i class="fas fa-spinner fa-spin fa-2x mb-3"></i>
368
+ <p>로딩 중...</p>
369
+ </div>
370
+ </div>
371
+ <div class="mt-3 text-muted small">
372
+ <i class="fas fa-lightbulb me-1"></i>
373
+ 이 프롬프트는 프로젝트의 초기 설정, Mem0 Facts, Zep Context를 종합하여 자동으로 생성됩니다.
374
+ </div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ </div>
379
+ {% endblock %}
380
+
381
+ {% block extra_js %}
382
+ <script>
383
+ // 모델 목록 로드
384
+ async function loadModels() {
385
+ const modelSelect = document.getElementById('modelSelect');
386
+ const refreshBtn = document.getElementById('refreshModels');
387
+
388
+ refreshBtn.disabled = true;
389
+ const icon = refreshBtn.querySelector('i');
390
+ icon.classList.add('fa-spin');
391
+
392
+ try {
393
+ // workspace와 동일하게 ?all=true 사용
394
+ const response = await fetch('/api/ollama/models?all=true');
395
+ const data = await response.json();
396
+
397
+ modelSelect.innerHTML = '<option value="">모델 선택...</option>';
398
+
399
+ if (data.models && data.models.length > 0) {
400
+ // Gemini 모델 그룹
401
+ const geminiModels = data.models.filter(m => m.type === 'gemini');
402
+ if (geminiModels.length > 0) {
403
+ const group = document.createElement('optgroup');
404
+ group.label = '✨ Gemini 모델';
405
+ geminiModels.forEach(m => {
406
+ const opt = document.createElement('option');
407
+ opt.value = m.name;
408
+ opt.textContent = m.name.startsWith('gemini:') ? m.name.substring(7) : m.name;
409
+ group.appendChild(opt);
410
+ });
411
+ modelSelect.appendChild(group);
412
+ }
413
+
414
+ // Ollama 모델 그룹
415
+ const ollamaModels = data.models.filter(m => m.type !== 'gemini');
416
+ if (ollamaModels.length > 0) {
417
+ const group = document.createElement('optgroup');
418
+ group.label = '🤖 Ollama 모델';
419
+ ollamaModels.forEach(m => {
420
+ const opt = document.createElement('option');
421
+ opt.value = m.name;
422
+ opt.textContent = m.name;
423
+ group.appendChild(opt);
424
+ });
425
+ modelSelect.appendChild(group);
426
+ }
427
+
428
+ // workspace와 동일한 localStorage 키 'lastWriterModel' 사용
429
+ const lastModel = localStorage.getItem('lastWriterModel');
430
+ if (lastModel) {
431
+ modelSelect.value = lastModel;
432
+ } else if (data.models.length > 0) {
433
+ // 기본값 설정 (Gemini 우선)
434
+ const defaultModel = data.models.find(m => m.name.includes('gemini')) || data.models[0];
435
+ modelSelect.value = defaultModel.name;
436
+ }
437
+ }
438
+ } catch (error) {
439
+ console.error('모델 로드 오류:', error);
440
+ modelSelect.innerHTML = '<option value="">로드 실패</option>';
441
+ } finally {
442
+ refreshBtn.disabled = false;
443
+ icon.classList.remove('fa-spin');
444
+ }
445
+ }
446
+
447
+ // 모델 선택 변경 시 저장
448
+ document.getElementById('modelSelect').addEventListener('change', function() {
449
+ localStorage.setItem('lastWriterModel', this.value);
450
+ });
451
+
452
+ document.getElementById('refreshModels').addEventListener('click', loadModels);
453
+
454
+ // 초기화 시 모델 로드
455
+ document.addEventListener('DOMContentLoaded', loadModels);
456
+
457
+ // 시스템 프롬프트 로드 함수
458
+ async function loadSystemPrompt() {
459
+ const promptContent = document.getElementById('promptContent');
460
+ if (!promptContent) return;
461
+
462
+ try {
463
+ const response = await fetch('/creation/api/projects/{{ project.project_id }}/system-prompt');
464
+ const data = await response.json();
465
+
466
+ if (response.ok && data.system_prompt) {
467
+ // 프롬프트를 보기 좋게 포맷팅
468
+ const formatted = data.system_prompt
469
+ .replace(/\n/g, '<br>')
470
+ .replace(/\[([^\]]+)\]/g, '<strong class="text-primary">[$1]</strong>')
471
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
472
+
473
+ promptContent.innerHTML = `
474
+ <div class="mb-3 p-3 bg-white rounded border-start border-primary border-4">
475
+ <div class="small text-muted mb-1">프로젝트 정보</div>
476
+ <div class="fw-bold">${data.project_title || '{{ project.title }}'}</div>
477
+ <div class="small text-muted mt-1">모드: ${data.project_mode || '{{ project.mode }}'}</div>
478
+ </div>
479
+ <div class="font-monospace small" style="line-height: 1.8; white-space: pre-wrap;">${formatted}</div>
480
+ `;
481
+ } else {
482
+ promptContent.innerHTML = `
483
+ <div class="text-center text-danger py-5">
484
+ <i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
485
+ <p>프롬프트를 불러올 수 없습니다: ${data.error || '알 수 없는 오류'}</p>
486
+ </div>
487
+ `;
488
+ }
489
+ } catch (error) {
490
+ console.error('프롬프트 로드 오류:', error);
491
+ promptContent.innerHTML = `
492
+ <div class="text-center text-danger py-5">
493
+ <i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
494
+ <p>서버 통신 실패</p>
495
+ </div>
496
+ `;
497
+ }
498
+ }
499
+
500
+ // 프롬프트 탭이 활성화될 때 로드
501
+ document.getElementById('prompt-tab')?.addEventListener('shown.bs.tab', function() {
502
+ loadSystemPrompt();
503
+ });
504
+
505
+ // 새로고침 버튼
506
+ document.getElementById('refreshPrompt')?.addEventListener('click', function() {
507
+ const icon = this.querySelector('i');
508
+ icon.classList.add('fa-spin');
509
+ loadSystemPrompt().finally(() => {
510
+ icon.classList.remove('fa-spin');
511
+ });
512
+ });
513
+
514
+ // 단계 업데이트 함수
515
+ function updateStep(stepId, status) {
516
+ const el = document.getElementById('step-' + stepId);
517
+ if (!el) return;
518
+
519
+ const icon = el.querySelector('.step-icon');
520
+ const check = el.querySelector('.step-check');
521
+ const pending = el.querySelector('.step-pending');
522
+
523
+ icon.style.display = 'none';
524
+ check.style.display = 'none';
525
+ pending.style.display = 'none';
526
+ el.classList.remove('text-primary', 'text-success', 'fw-bold');
527
+
528
+ if (status === 'running') {
529
+ icon.style.display = 'inline-block';
530
+ el.classList.add('text-primary', 'fw-bold');
531
+ } else if (status === 'done') {
532
+ check.style.display = 'inline-block';
533
+ el.classList.add('text-success');
534
+ } else {
535
+ pending.style.display = 'inline-block';
536
+ }
537
+ }
538
+
539
+ document.getElementById('saveInitialSettings').addEventListener('click', async function() {
540
+ const settings = document.getElementById('initialSettings').value;
541
+ const model = document.getElementById('modelSelect').value;
542
+ const btn = this;
543
+ const progressArea = document.getElementById('analysisProgress');
544
+
545
+ if (!settings.trim()) {
546
+ alert('내용을 입력해주세요.');
547
+ return;
548
+ }
549
+
550
+ if (!model) {
551
+ alert('분석에 사용할 AI 모델을 선택해주세요.');
552
+ return;
553
+ }
554
+
555
+ btn.disabled = true;
556
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> 분석 중...';
557
+ progressArea.style.display = 'block';
558
+
559
+ // 단계 초기화
560
+ ['parent', 'chunks', 'graph', 'final'].forEach(s => updateStep(s, 'pending'));
561
+
562
+ try {
563
+ // 1. 설정 저장 및 분석 시작 (Backend에서 모든 단계를 순차적으로 처리하거나, 첫 단계를 시작)
564
+ updateStep('parent', 'running');
565
+ const response = await fetch('/creation/api/projects/{{ project.project_id }}/initial-settings', {
566
+ method: 'POST',
567
+ headers: { 'Content-Type': 'application/json' },
568
+ body: JSON.stringify({ settings, model })
569
+ });
570
+
571
+ const data = await response.json();
572
+ if (!response.ok) throw new Error(data.error || '저장 및 분석 오류');
573
+
574
+ updateStep('parent', 'done');
575
+
576
+ // 2~4단계는 백엔드에서 단일 요청으로 처리할 수도 있고, 프론트에서 순차적으로 부를 수도 있습니다.
577
+ // 여기서는 백엔드가 모든 처리를 한 번에 하고 결과를 돌려주는 것으로 가정합니다.
578
+ // (만약 단계별로 나누고 싶다면 여기서 추가 fetch 호출 가능)
579
+
580
+ updateStep('chunks', 'done');
581
+ updateStep('graph', 'done');
582
+ updateStep('final', 'done');
583
+
584
+ alert('초기 설정이 성공적으로 분석되었습니다.');
585
+ location.reload();
586
+
587
+ } catch (error) {
588
+ alert('분석 실패: ' + error.message);
589
+ console.error(error);
590
+ } finally {
591
+ btn.disabled = false;
592
+ btn.innerHTML = '<i class="fas fa-save me-1"></i> 저장 및 분석 시작';
593
+ }
594
+ });
595
+
596
+ // 커스텀 프롬프트 저장
597
+ document.getElementById('saveCustomPrompt')?.addEventListener('click', async function() {
598
+ const custom_system_prompt = document.getElementById('customSystemPrompt').value;
599
+ const btn = this;
600
+
601
+ btn.disabled = true;
602
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> 저장 중...';
603
+
604
+ try {
605
+ const response = await fetch('/creation/api/projects/{{ project.project_id }}/custom-prompt', {
606
+ method: 'POST',
607
+ headers: { 'Content-Type': 'application/json' },
608
+ body: JSON.stringify({ custom_system_prompt })
609
+ });
610
+
611
+ const data = await response.json();
612
+ if (!response.ok) throw new Error(data.error || '저장 오류');
613
+
614
+ alert('작가 지침이 저장되었습니다.');
615
+ // 질문 조건 확인 탭의 내용도 갱신될 수 있도록 함 (다음 로드 시)
616
+ } catch (error) {
617
+ alert('저장 실패: ' + error.message);
618
+ console.error(error);
619
+ } finally {
620
+ btn.disabled = false;
621
+ btn.innerHTML = '<i class="fas fa-check-circle me-1"></i> 지침 저장하기';
622
+ }
623
+ });
624
+ </script>
625
+ {% endblock %}
626
+
templates/novel_dashboard.html CHANGED
@@ -1,10 +1,25 @@
1
- {% extends "admin.html" %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  {% block content %}
4
- <div class="container-fluid mt-4">
5
  <div class="d-flex justify-content-between align-items-center mb-4">
6
- <h2><i class="fas fa-book-open me-2"></i>웹소설 프로젝트 대시보드</h2>
7
- <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createProjectModal">
8
  <i class="fas fa-plus me-1"></i> 새 프로젝트 생성
9
  </button>
10
  </div>
@@ -22,46 +37,54 @@
22
 
23
  <!-- 프로젝트 생성 모달 -->
24
  <div class="modal fade" id="createProjectModal" tabindex="-1" aria-hidden="true">
25
- <div class="modal-dialog">
26
- <div class="modal-content">
27
- <div class="modal-header">
28
- <h5 class="modal-title">새 웹소설 프로젝트</h5>
29
  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
30
  </div>
31
- <div class="modal-body">
32
  <form id="createProjectForm">
33
  <div class="mb-3">
34
- <label class="form-label">제목</label>
35
  <input type="text" class="form-control" id="projectTitle" required placeholder="소설 제목을 입력하세요">
36
  </div>
37
  <div class="mb-3">
38
- <label class="form-label">창작 모드</label>
39
  <select class="form-select" id="projectMode">
40
  <option value="CREATIVE">순수 창작 (Pure Imagination)</option>
41
  <option value="REFERENCE">기존 작품 참고 (RAG Enabled)</option>
42
  </select>
 
 
 
43
  </div>
44
  <div class="mb-3 d-none" id="referenceSection">
45
- <label class="form-label">참고 자료 ID (RAG Collection)</label>
46
  <input type="text" class="form-control" id="referenceId" placeholder="참고할 벡터 컬렉션 ID">
47
  </div>
48
  </form>
49
  </div>
50
- <div class="modal-footer">
51
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
52
- <button type="button" class="btn btn-primary" onclick="createNewProject()">생성하기</button>
53
  </div>
54
  </div>
55
  </div>
56
  </div>
 
57
 
 
58
  <script>
59
  document.getElementById('projectMode').addEventListener('change', function() {
60
  const refSection = document.getElementById('referenceSection');
 
61
  if (this.value === 'REFERENCE') {
62
  refSection.classList.remove('d-none');
 
63
  } else {
64
  refSection.classList.add('d-none');
 
65
  }
66
  });
67
 
@@ -74,26 +97,33 @@ async function loadProjects() {
74
  if (projects.length === 0) {
75
  container.innerHTML = `
76
  <div class="col-12 text-center py-5 text-muted">
77
- <i class="fas fa-folder-open fa-3x mb-3"></i>
78
- <p>생성된 프로젝트가 없습니다. 첫 프로젝트를 만들어보세요!</p>
 
79
  </div>`;
80
  return;
81
  }
82
 
83
  container.innerHTML = projects.map(p => `
84
  <div class="col-md-4 mb-4">
85
- <div class="card h-100 shadow-sm">
86
- <div class="card-body">
87
- <div class="d-flex justify-content-between align-items-start mb-2">
88
- <h5 class="card-title text-truncate" style="max-width: 80%;">${p.title}</h5>
89
- <span class="badge ${p.mode === 'CREATIVE' ? 'bg-success' : 'bg-info'}">${p.mode}</span>
90
  </div>
91
- <p class="card-text text-muted small">ID: ${p.project_id.substring(0, 8)}...</p>
92
  </div>
93
- <div class="card-footer bg-transparent border-top-0 d-grid">
94
- <a href="/creation/workspace/${p.project_id}" class="btn btn-outline-primary">
95
  <i class="fas fa-pen-nib me-1"></i> 집필 시작
96
  </a>
 
 
 
 
 
 
97
  </div>
98
  </div>
99
  </div>
@@ -118,7 +148,9 @@ async function createNewProject() {
118
  });
119
 
120
  if (response.ok) {
121
- bootstrap.Modal.getInstance(document.getElementById('createProjectModal')).hide();
 
 
122
  loadProjects();
123
  }
124
  } catch (error) {
@@ -126,7 +158,28 @@ async function createNewProject() {
126
  }
127
  }
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  document.addEventListener('DOMContentLoaded', loadProjects);
130
  </script>
131
  {% endblock %}
132
-
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}웹소설 대시보드 - SOYMEDIA{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <style>
7
+ .card {
8
+ border: none;
9
+ border-radius: 12px;
10
+ transition: transform 0.2s;
11
+ }
12
+ .card:hover {
13
+ transform: translateY(-5px);
14
+ }
15
+ </style>
16
+ {% endblock %}
17
 
18
  {% block content %}
19
+ <div class="container-fluid mt-4 px-4">
20
  <div class="d-flex justify-content-between align-items-center mb-4">
21
+ <h2><i class="fas fa-book-open me-2 text-primary"></i>웹소설 프로젝트 대시보드</h2>
22
+ <button class="btn btn-primary btn-lg shadow-sm" data-bs-toggle="modal" data-bs-target="#createProjectModal">
23
  <i class="fas fa-plus me-1"></i> 새 프로젝트 생성
24
  </button>
25
  </div>
 
37
 
38
  <!-- 프로젝트 생성 모달 -->
39
  <div class="modal fade" id="createProjectModal" tabindex="-1" aria-hidden="true">
40
+ <div class="modal-dialog modal-dialog-centered">
41
+ <div class="modal-content border-0 shadow">
42
+ <div class="modal-header bg-light">
43
+ <h5 class="modal-title font-weight-bold">새 웹소설 프로젝트</h5>
44
  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
45
  </div>
46
+ <div class="modal-body p-4">
47
  <form id="createProjectForm">
48
  <div class="mb-3">
49
+ <label class="form-label fw-medium">제목</label>
50
  <input type="text" class="form-control" id="projectTitle" required placeholder="소설 제목을 입력하세요">
51
  </div>
52
  <div class="mb-3">
53
+ <label class="form-label fw-medium">창작 모드</label>
54
  <select class="form-select" id="projectMode">
55
  <option value="CREATIVE">순수 창작 (Pure Imagination)</option>
56
  <option value="REFERENCE">기존 작품 참고 (RAG Enabled)</option>
57
  </select>
58
+ <div class="form-text mt-2 small text-muted" id="modeDesc">
59
+ 순수 창작 모드에서는 AI의 상상력만으로 집필을 진행합니다.
60
+ </div>
61
  </div>
62
  <div class="mb-3 d-none" id="referenceSection">
63
+ <label class="form-label fw-medium">참고 자료 ID (RAG Collection)</label>
64
  <input type="text" class="form-control" id="referenceId" placeholder="참고할 벡터 컬렉션 ID">
65
  </div>
66
  </form>
67
  </div>
68
+ <div class="modal-footer border-top-0 p-3">
69
+ <button type="button" class="btn btn-light" data-bs-dismiss="modal">취소</button>
70
+ <button type="button" class="btn btn-primary px-4" onclick="createNewProject()">생성하기</button>
71
  </div>
72
  </div>
73
  </div>
74
  </div>
75
+ {% endblock %}
76
 
77
+ {% block extra_js %}
78
  <script>
79
  document.getElementById('projectMode').addEventListener('change', function() {
80
  const refSection = document.getElementById('referenceSection');
81
+ const modeDesc = document.getElementById('modeDesc');
82
  if (this.value === 'REFERENCE') {
83
  refSection.classList.remove('d-none');
84
+ modeDesc.innerText = "기존 작품이나 설정 자료를 AI가 참고하여 집필을 돕습니다.";
85
  } else {
86
  refSection.classList.add('d-none');
87
+ modeDesc.innerText = "순수 창작 모드에서는 AI의 상상력만으로 집필을 진행합니다.";
88
  }
89
  });
90
 
 
97
  if (projects.length === 0) {
98
  container.innerHTML = `
99
  <div class="col-12 text-center py-5 text-muted">
100
+ <i class="fas fa-folder-open fa-3x mb-3 opacity-25"></i>
101
+ <p class="h5">생성된 프로젝트가 없습니다.</p>
102
+ <p>우측 상단의 버튼을 눌러 첫 프로젝트를 만들어보세요!</p>
103
  </div>`;
104
  return;
105
  }
106
 
107
  container.innerHTML = projects.map(p => `
108
  <div class="col-md-4 mb-4">
109
+ <div class="card h-100 shadow-sm border-0">
110
+ <div class="card-body p-4">
111
+ <div class="d-flex justify-content-between align-items-start mb-3">
112
+ <h5 class="card-title fw-bold text-truncate" style="max-width: 75%;">${p.title}</h5>
113
+ <span class="badge ${p.mode === 'CREATIVE' ? 'bg-success-subtle text-success' : 'bg-info-subtle text-info'} px-2 py-1">${p.mode}</span>
114
  </div>
115
+ <p class="card-text text-muted small mb-0">ID: ${p.project_id.substring(0, 8)}...</p>
116
  </div>
117
+ <div class="card-footer bg-white border-top-0 p-4 pt-0 d-flex gap-2">
118
+ <a href="/creation/workspace/${p.project_id}" class="btn btn-outline-primary fw-medium flex-fill">
119
  <i class="fas fa-pen-nib me-1"></i> 집필 시작
120
  </a>
121
+ <a href="/creation/analysis/${p.project_id}" class="btn btn-outline-info fw-medium flex-fill">
122
+ <i class="fas fa-chart-line me-1"></i> 분석 확인
123
+ </a>
124
+ <button class="btn btn-outline-danger px-3" onclick="deleteProject('${p.project_id}', '${p.title.replace(/'/g, "\\'")}')" title="프로젝트 삭제">
125
+ <i class="fas fa-trash-alt"></i> 삭제
126
+ </button>
127
  </div>
128
  </div>
129
  </div>
 
148
  });
149
 
150
  if (response.ok) {
151
+ const modalElement = document.getElementById('createProjectModal');
152
+ const modal = bootstrap.Modal.getInstance(modalElement) || new bootstrap.Modal(modalElement);
153
+ modal.hide();
154
  loadProjects();
155
  }
156
  } catch (error) {
 
158
  }
159
  }
160
 
161
+ async function deleteProject(projectId, title) {
162
+ if (!confirm(`'${title}' 프로젝트를 삭제하시겠습니까?\n삭제 후에는 복구할 수 없습니다.`)) {
163
+ return;
164
+ }
165
+
166
+ try {
167
+ const response = await fetch(`/creation/api/projects/${projectId}`, {
168
+ method: 'DELETE'
169
+ });
170
+
171
+ if (response.ok) {
172
+ loadProjects();
173
+ } else {
174
+ const data = await response.json();
175
+ alert('삭제 실패: ' + (data.error || '알 수 없는 오류'));
176
+ }
177
+ } catch (error) {
178
+ console.error('Delete error:', error);
179
+ alert('서버 통신 실패');
180
+ }
181
+ }
182
+
183
  document.addEventListener('DOMContentLoaded', loadProjects);
184
  </script>
185
  {% endblock %}
 
templates/novel_viewer.html ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ project.title }} - Viewer - SOYMEDIA</title>
7
+ <!-- Font Awesome -->
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ <!-- Marked.js for Markdown rendering -->
10
+ <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
11
+ <style>
12
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
13
+
14
+ :root {
15
+ --bg-main: #0f172a;
16
+ --bg-message-ai: #1e293b;
17
+ --border-color: #1e293b;
18
+ --text-main: #f1f5f9;
19
+ --text-muted: #94a3b8;
20
+ --accent: #3b82f6;
21
+ }
22
+
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ background-color: var(--bg-main);
31
+ color: var(--text-main);
32
+ font-family: 'Inter', 'Noto Sans KR', sans-serif;
33
+ min-height: 100vh;
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+
38
+ .viewer-header {
39
+ background-color: rgba(15, 23, 42, 0.95);
40
+ border-bottom: 1px solid var(--border-color);
41
+ padding: 24px;
42
+ text-align: center;
43
+ backdrop-filter: blur(8px);
44
+ }
45
+
46
+ .viewer-header h1 {
47
+ font-size: 24px;
48
+ font-weight: 700;
49
+ margin-bottom: 8px;
50
+ color: var(--text-main);
51
+ }
52
+
53
+ .viewer-header p {
54
+ font-size: 12px;
55
+ color: var(--text-muted);
56
+ text-transform: uppercase;
57
+ letter-spacing: 0.1em;
58
+ }
59
+
60
+ .viewer-content {
61
+ flex: 1;
62
+ max-width: 900px;
63
+ width: 100%;
64
+ margin: 0 auto;
65
+ padding: 48px 24px;
66
+ }
67
+
68
+ .viewer-message {
69
+ background-color: var(--bg-message-ai);
70
+ border: 1px solid var(--border-color);
71
+ border-radius: 16px;
72
+ padding: 32px;
73
+ margin-bottom: 24px;
74
+ }
75
+
76
+ .viewer-message-header {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 12px;
80
+ margin-bottom: 20px;
81
+ padding-bottom: 16px;
82
+ border-bottom: 1px solid var(--border-color);
83
+ }
84
+
85
+ .viewer-message-icon {
86
+ background-color: rgba(59, 130, 246, 0.1);
87
+ padding: 8px;
88
+ border-radius: 8px;
89
+ color: var(--accent);
90
+ width: 40px;
91
+ height: 40px;
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ }
96
+
97
+ .viewer-message-title {
98
+ font-size: 14px;
99
+ font-weight: 600;
100
+ color: var(--text-main);
101
+ }
102
+
103
+ .viewer-message-content {
104
+ font-size: 16px;
105
+ line-height: 1.8;
106
+ color: var(--text-main);
107
+ white-space: pre-wrap;
108
+ }
109
+
110
+ .viewer-message-content.markdown-content {
111
+ white-space: pre-wrap;
112
+ }
113
+
114
+ .viewer-message-content.markdown-content h1,
115
+ .viewer-message-content.markdown-content h2,
116
+ .viewer-message-content.markdown-content h3,
117
+ .viewer-message-content.markdown-content h4,
118
+ .viewer-message-content.markdown-content h5,
119
+ .viewer-message-content.markdown-content h6 {
120
+ margin: 20px 0 12px 0;
121
+ font-weight: 600;
122
+ line-height: 1.3;
123
+ white-space: normal;
124
+ }
125
+
126
+ .viewer-message-content.markdown-content h1 {
127
+ font-size: 1.8em;
128
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
129
+ padding-bottom: 8px;
130
+ }
131
+
132
+ .viewer-message-content.markdown-content h2 {
133
+ font-size: 1.5em;
134
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
135
+ padding-bottom: 6px;
136
+ }
137
+
138
+ .viewer-message-content.markdown-content h3 {
139
+ font-size: 1.2em;
140
+ }
141
+
142
+ .viewer-message-content.markdown-content p {
143
+ margin: 12px 0;
144
+ white-space: pre-wrap;
145
+ }
146
+
147
+ .viewer-message-content.markdown-content ul,
148
+ .viewer-message-content.markdown-content ol {
149
+ margin: 12px 0;
150
+ padding-left: 28px;
151
+ }
152
+
153
+ .viewer-message-content.markdown-content li {
154
+ margin: 6px 0;
155
+ }
156
+
157
+ .viewer-message-content.markdown-content code {
158
+ background-color: rgba(30, 41, 59, 0.5);
159
+ padding: 2px 6px;
160
+ border-radius: 4px;
161
+ font-family: 'Courier New', monospace;
162
+ font-size: 0.9em;
163
+ }
164
+
165
+ .viewer-message-content.markdown-content pre {
166
+ background-color: rgba(30, 41, 59, 0.5);
167
+ padding: 16px;
168
+ border-radius: 8px;
169
+ overflow-x: auto;
170
+ margin: 16px 0;
171
+ }
172
+
173
+ .viewer-message-content.markdown-content pre code {
174
+ background: none;
175
+ padding: 0;
176
+ }
177
+
178
+ .viewer-message-content.markdown-content blockquote {
179
+ border-left: 4px solid rgba(59, 130, 246, 0.5);
180
+ padding-left: 16px;
181
+ margin: 16px 0;
182
+ color: var(--text-muted);
183
+ font-style: italic;
184
+ }
185
+
186
+ .viewer-message-content.markdown-content strong {
187
+ font-weight: 600;
188
+ color: var(--text-main);
189
+ }
190
+
191
+ .viewer-message-content.markdown-content em {
192
+ font-style: italic;
193
+ }
194
+
195
+ .viewer-message-content.markdown-content a {
196
+ color: var(--accent);
197
+ text-decoration: underline;
198
+ }
199
+
200
+ .viewer-empty {
201
+ text-align: center;
202
+ padding: 64px 24px;
203
+ color: var(--text-muted);
204
+ }
205
+
206
+ .viewer-empty-icon {
207
+ font-size: 48px;
208
+ margin-bottom: 16px;
209
+ opacity: 0.5;
210
+ }
211
+
212
+ .viewer-empty-text {
213
+ font-size: 16px;
214
+ }
215
+
216
+ @media (max-width: 768px) {
217
+ .viewer-content {
218
+ padding: 24px 16px;
219
+ }
220
+
221
+ .viewer-message {
222
+ padding: 24px;
223
+ }
224
+
225
+ .viewer-header h1 {
226
+ font-size: 20px;
227
+ }
228
+ }
229
+
230
+ /* Custom Scrollbar */
231
+ ::-webkit-scrollbar { width: 6px; }
232
+ ::-webkit-scrollbar-track { background: transparent; }
233
+ ::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
234
+ ::-webkit-scrollbar-thumb:hover { background: #334155; }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <header class="viewer-header">
239
+ <h1>{{ project.title }}</h1>
240
+ <p>Public Viewer</p>
241
+ </header>
242
+
243
+ <main class="viewer-content">
244
+ {% if selected_messages and selected_messages|length > 0 %}
245
+ <div class="viewer-message">
246
+ <div class="viewer-message-header">
247
+ <div class="viewer-message-icon">
248
+ <i class="fas fa-book-open"></i>
249
+ </div>
250
+ <div class="viewer-message-title">작품 내용</div>
251
+ </div>
252
+ <div class="viewer-message-content markdown-content" id="combined-message-content">
253
+ <!-- 모든 메시지 내용이 여기에 연속으로 렌더링됩니다 -->
254
+ </div>
255
+ </div>
256
+
257
+ <script type="application/json" id="messages-data">
258
+ {{ selected_messages|tojson|safe }}
259
+ </script>
260
+ {% else %}
261
+ <div class="viewer-empty">
262
+ <div class="viewer-empty-icon">
263
+ <i class="fas fa-inbox"></i>
264
+ </div>
265
+ <div class="viewer-empty-text">
266
+ 표시할 메시지가 선택되지 않았습니다.
267
+ </div>
268
+ </div>
269
+ {% endif %}
270
+ </main>
271
+
272
+ <script>
273
+ document.addEventListener('DOMContentLoaded', () => {
274
+ // Markdown 렌더링 함수
275
+ function renderMarkdown(text) {
276
+ if (!text) {
277
+ console.warn('No content to render');
278
+ return '<p style="color: var(--text-muted);">내용이 없습니다.</p>';
279
+ }
280
+ try {
281
+ if (typeof marked !== 'undefined') {
282
+ // 연속된 공백을 보존하기 위해 먼저 변환
283
+ const preservedText = text.replace(/ {2,}/g, (match) => {
284
+ return '&nbsp;'.repeat(match.length);
285
+ });
286
+ return marked.parse(preservedText);
287
+ } else {
288
+ // marked.js가 로드되지 않은 경우 기본 텍스트 반환
289
+ return text.replace(/ {2,}/g, (match) => {
290
+ return '&nbsp;'.repeat(match.length);
291
+ }).replace(/\n/g, '<br>');
292
+ }
293
+ } catch (e) {
294
+ console.error('Markdown rendering error:', e);
295
+ return text.replace(/ {2,}/g, (match) => {
296
+ return '&nbsp;'.repeat(match.length);
297
+ }).replace(/\n/g, '<br>');
298
+ }
299
+ }
300
+
301
+ // 메시지 데이터 가져오기
302
+ const messagesDataElement = document.getElementById('messages-data');
303
+ const combinedContentElement = document.getElementById('combined-message-content');
304
+
305
+ if (messagesDataElement && combinedContentElement) {
306
+ try {
307
+ const messagesData = JSON.parse(messagesDataElement.textContent);
308
+
309
+ // 모든 메시지 내용�� 하나로 합치기
310
+ let combinedContent = '';
311
+ messagesData.forEach((message, index) => {
312
+ if (message.content) {
313
+ // 각 메시지 사이에 구분선 추가 (첫 번째 메시지가 아닌 경우)
314
+ if (index > 0) {
315
+ combinedContent += '\n\n---\n\n';
316
+ }
317
+ combinedContent += message.content;
318
+ }
319
+ });
320
+
321
+ // 합쳐진 내용을 마크다운으로 렌더링
322
+ if (combinedContent) {
323
+ combinedContentElement.innerHTML = renderMarkdown(combinedContent);
324
+ } else {
325
+ combinedContentElement.innerHTML = '<p style="color: var(--text-muted);">내용이 없습니다.</p>';
326
+ }
327
+ } catch (e) {
328
+ console.error('Failed to parse messages data:', e);
329
+ if (combinedContentElement) {
330
+ combinedContentElement.innerHTML = '<p style="color: var(--text-muted);">메시지 내용을 불러올 수 없습니다.</p>';
331
+ }
332
+ }
333
+ } else {
334
+ console.error('Messages data element or combined content element not found');
335
+ }
336
+ });
337
+ </script>
338
+ </body>
339
+ </html>
340
+
templates/novel_workspace.html CHANGED
@@ -1,190 +1,1157 @@
1
- {% extends "admin.html" %}
2
 
3
- {% block content %}
 
 
 
 
 
 
4
  <style>
5
- .workspace-container {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  display: flex;
7
- height: calc(100vh - 100px);
8
- background: #f8f9fa;
9
  overflow: hidden;
10
  }
11
- .editor-area {
 
 
 
 
 
 
 
 
 
12
  flex: 1;
13
  display: flex;
14
  flex-direction: column;
15
- padding: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  overflow-y: auto;
 
17
  }
18
- .memory-sidebar {
19
- width: 320px;
20
- background: white;
21
- border-left: 1px solid #dee2e6;
22
  display: flex;
23
  flex-direction: column;
24
- padding: 15px;
25
- overflow-y: auto;
26
  }
27
- .chat-bubble {
28
- max-width: 80%;
29
- margin-bottom: 15px;
30
- padding: 12px 16px;
31
- border-radius: 15px;
32
- position: relative;
33
  }
34
- .bubble-user {
 
35
  align-self: flex-end;
36
- background: #007bff;
37
- color: white;
38
- border-bottom-right-radius: 2px;
39
  }
40
- .bubble-ai {
 
41
  align-self: flex-start;
42
- background: #e9ecef;
43
- color: #212529;
44
- border-bottom-left-radius: 2px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
- .input-box {
47
- background: white;
48
- padding: 15px;
49
- border-top: 1px solid #dee2e6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  display: flex;
51
- gap: 10px;
 
52
  }
53
- .memory-section {
54
- margin-bottom: 25px;
 
55
  }
56
- .memory-section h6 {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  font-weight: 700;
58
- color: #495057;
59
  text-transform: uppercase;
60
- font-size: 0.8rem;
61
- margin-bottom: 10px;
 
 
 
 
 
 
 
 
 
 
62
  display: flex;
63
  align-items: center;
64
- gap: 5px;
 
65
  }
66
- .fact-item {
67
- background: #fff3cd;
68
- padding: 8px 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  border-radius: 8px;
70
- font-size: 0.9rem;
71
- margin-bottom: 8px;
72
- border-left: 4px solid #ffc107;
73
  }
74
- .summary-text {
75
- font-size: 0.9rem;
76
- color: #6c757d;
77
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
  </style>
 
80
 
81
- <div class="workspace-container">
82
- <!-- 집필 구역 (에디터/채팅) -->
83
- <div class="editor-area">
84
- <div class="d-flex justify-content-between align-items-center mb-4">
85
- <h4><i class="fas fa-edit me-2"></i>{{ project.title }} <small class="text-muted">({{ project.mode }})</small></h4>
86
- <div id="loadingStatus" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
87
- </div>
88
-
89
- <div id="chatWindow" class="d-flex flex-column flex-grow-1" style="min-height: 300px;">
90
- <div class="chat-bubble bubble-ai">
91
- 안녕하세요! 소설 <b>"{{ project.title }}"</b>의 집필을 도와드릴 AI 어시스턴트입니다.
92
- 캐릭터 설정이나 다음 전개에 대해 무엇이든 물어보세요.
93
  </div>
94
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- <div class="input-box mt-auto">
97
- <textarea id="userInput" class="form-control" rows="2" placeholder="에이전트에게 지시하거나 질문하세요... (Enter로 전송)"></textarea>
98
- <button id="sendBtn" class="btn btn-primary" onclick="sendMessage()">
99
- <i class="fas fa-paper-plane"></i>
100
- </button>
101
- </div>
102
- </div>
103
-
104
- <!-- 메모리 사이드바 -->
105
- <div class="memory-sidebar">
106
- <div class="memory-section">
107
- <h6><i class="fas fa-users text-warning"></i> 캐릭터 및 설정 (Mem0)</h6>
108
- <div id="factContainer">
109
- <div class="text-muted small">설정을 불러오는 중...</div>
110
  </div>
111
- </div>
112
 
113
- <div class="memory-section">
114
- <h6><i class="fas fa-history text-info"></i> 스토리 문맥 요약 (Zep)</h6>
115
- <div id="summaryContainer" class="summary-text italic">
116
- 요약불러오는 중...
 
 
 
 
 
 
 
117
  </div>
118
- </div>
 
119
 
120
- <div class="mt-auto">
121
- <button class="btn btn-sm btn-outline-secondary w-100" onclick="refreshMemory()">
122
- <i class="fas fa-sync-alt me-1"></i> 동기화
123
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
- </div>
126
  </div>
127
 
128
  <script>
129
- const projectId = "{{ project.project_id }}";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- async function sendMessage() {
132
- const input = document.getElementById('userInput');
133
- const message = input.value.trim();
134
- if (!message) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- appendBubble(message, 'user');
137
- input.value = '';
138
-
139
- document.getElementById('loadingStatus').classList.remove('d-none');
140
-
141
- try {
142
- const response = await fetch(`/creation/api/chat/${projectId}`, {
143
- method: 'POST',
144
- headers: { 'Content-Type': 'application/json' },
145
- body: JSON.stringify({ message })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  });
147
- const data = await response.json();
148
- appendBubble(data.reply, 'ai');
149
- } catch (error) {
150
- appendBubble("오류가 발생했습니다.", 'ai');
151
- } finally {
152
- document.getElementById('loadingStatus').classList.add('d-none');
153
- refreshMemory();
154
- }
155
- }
156
-
157
- function appendBubble(content, role) {
158
- const win = document.getElementById('chatWindow');
159
- const div = document.createElement('div');
160
- div.className = `chat-bubble bubble-${role}`;
161
- div.innerHTML = content.replace(/\n/g, '<br>');
162
- win.appendChild(div);
163
- win.scrollTop = win.scrollHeight;
164
- }
165
-
166
- async function refreshMemory() {
167
- // 실제로는 API를 통해 Zep/Mem0 데이터를 실시간 조회해야 함
168
- // 여기서는 목업 데이터를 표시하거나 에이전트 응답 시 함께 갱신
169
- try {
170
- // 임시 목업 데이터
171
- document.getElementById('factContainer').innerHTML = `
172
- <div class="fact-item">주인공: 김철수 (25세, 검사)</div>
173
- <div class="fact-item">배경: 2024년 가상 서울</div>
174
- `;
175
- document.getElementById('summaryContainer').innerText = "최근 철수는 강남역 근처에서 의문의 포탈을 발견했습니다.";
176
- } catch (e) {}
177
- }
178
-
179
- document.getElementById('userInput').addEventListener('keypress', function(e) {
180
- if (e.key === 'Enter' && !e.shiftKey) {
181
- e.preventDefault();
182
- sendMessage();
183
- }
184
- });
185
-
186
- // 초기 로드
187
- document.addEventListener('DOMContentLoaded', refreshMemory);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </script>
189
  {% endblock %}
190
-
 
1
+ {% extends "creation_base.html" %}
2
 
3
+ {% block title %}{{ project.title }} - Workspace - SOYMEDIA{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <!-- Font Awesome -->
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <!-- Marked.js for Markdown rendering -->
9
+ <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
10
  <style>
11
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
12
+
13
+ :root {
14
+ --bg-main: #0f172a;
15
+ --bg-sidebar: #0f172a;
16
+ --bg-message-ai: #1e293b;
17
+ --bg-message-user: #3b82f6;
18
+ --border-color: #1e293b;
19
+ --text-main: #f1f5f9;
20
+ --text-muted: #94a3b8;
21
+ --accent: #3b82f6;
22
+ }
23
+
24
+ body {
25
+ background-color: var(--bg-main);
26
+ color: var(--text-main);
27
+ font-family: 'Inter', 'Noto Sans KR', sans-serif;
28
+ height: 100vh;
29
+ margin: 0;
30
+ padding: 0;
31
  display: flex;
32
+ flex-direction: column;
 
33
  overflow: hidden;
34
  }
35
+
36
+ #app-container {
37
+ display: flex;
38
+ height: 100%;
39
+ width: 100%;
40
+ overflow: hidden;
41
+ }
42
+
43
+ /* Main Chat Section */
44
+ .chat-section {
45
  flex: 1;
46
  display: flex;
47
  flex-direction: column;
48
+ min-width: 0;
49
+ background-color: var(--bg-main);
50
+ }
51
+
52
+ .chat-header {
53
+ height: 64px;
54
+ border-bottom: 1px solid var(--border-color);
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: space-between;
58
+ padding: 0 24px;
59
+ background-color: rgba(15, 23, 42, 0.8);
60
+ backdrop-filter: blur(8px);
61
+ z-index: 10;
62
+ }
63
+
64
+ .chat-header-info {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 12px;
68
+ }
69
+
70
+ .chat-header-icon {
71
+ background-color: rgba(59, 130, 246, 0.1);
72
+ padding: 8px;
73
+ border-radius: 8px;
74
+ color: var(--accent);
75
+ }
76
+
77
+ .chat-header-title {
78
+ font-size: 14px;
79
+ font-weight: 600;
80
+ margin: 0;
81
+ }
82
+
83
+ .chat-header-subtitle {
84
+ font-size: 10px;
85
+ color: var(--text-muted);
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.05em;
88
+ margin: 0;
89
+ }
90
+
91
+ .chat-messages {
92
+ flex: 1;
93
  overflow-y: auto;
94
+ padding: 32px 16px;
95
  }
96
+
97
+ .chat-messages-inner {
98
+ max-width: 800px;
99
+ margin: 0 auto;
100
  display: flex;
101
  flex-direction: column;
102
+ gap: 24px;
 
103
  }
104
+
105
+ .message {
106
+ display: flex;
107
+ flex-direction: column;
108
+ max-width: 85%;
 
109
  }
110
+
111
+ .message-user {
112
  align-self: flex-end;
113
+ align-items: flex-end;
 
 
114
  }
115
+
116
+ .message-ai {
117
  align-self: flex-start;
118
+ align-items: flex-start;
119
+ }
120
+
121
+ .message-bubble {
122
+ padding: 12px 20px;
123
+ border-radius: 18px;
124
+ font-size: 15px;
125
+ line-height: 1.6;
126
+ white-space: pre-wrap;
127
+ }
128
+
129
+ .message-bubble.markdown-content {
130
+ white-space: pre-wrap;
131
+ }
132
+
133
+ .message-bubble.markdown-content h1,
134
+ .message-bubble.markdown-content h2,
135
+ .message-bubble.markdown-content h3,
136
+ .message-bubble.markdown-content h4,
137
+ .message-bubble.markdown-content h5,
138
+ .message-bubble.markdown-content h6 {
139
+ margin: 16px 0 8px 0;
140
+ font-weight: 600;
141
+ line-height: 1.3;
142
+ white-space: normal;
143
+ }
144
+
145
+ .message-bubble.markdown-content h1 {
146
+ font-size: 1.5em;
147
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
148
+ padding-bottom: 8px;
149
+ }
150
+
151
+ .message-bubble.markdown-content h2 {
152
+ font-size: 1.3em;
153
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
154
+ padding-bottom: 6px;
155
+ }
156
+
157
+ .message-bubble.markdown-content h3 {
158
+ font-size: 1.1em;
159
+ }
160
+
161
+ .message-bubble.markdown-content p {
162
+ margin: 8px 0;
163
+ white-space: pre-wrap;
164
+ }
165
+
166
+ .message-bubble.markdown-content ul,
167
+ .message-bubble.markdown-content ol {
168
+ margin: 8px 0;
169
+ padding-left: 24px;
170
+ }
171
+
172
+ .message-bubble.markdown-content li {
173
+ margin: 4px 0;
174
+ }
175
+
176
+ .message-bubble.markdown-content code {
177
+ background-color: rgba(30, 41, 59, 0.5);
178
+ padding: 2px 6px;
179
+ border-radius: 4px;
180
+ font-family: 'Courier New', monospace;
181
+ font-size: 0.9em;
182
+ }
183
+
184
+ .message-bubble.markdown-content pre {
185
+ background-color: rgba(30, 41, 59, 0.5);
186
+ padding: 12px;
187
+ border-radius: 8px;
188
+ overflow-x: auto;
189
+ margin: 12px 0;
190
+ }
191
+
192
+ .message-bubble.markdown-content pre code {
193
+ background: none;
194
+ padding: 0;
195
  }
196
+
197
+ .message-bubble.markdown-content blockquote {
198
+ border-left: 4px solid rgba(59, 130, 246, 0.5);
199
+ padding-left: 16px;
200
+ margin: 12px 0;
201
+ color: var(--text-muted);
202
+ font-style: italic;
203
+ }
204
+
205
+ .message-bubble.markdown-content strong {
206
+ font-weight: 600;
207
+ color: var(--text-main);
208
+ }
209
+
210
+ .message-bubble.markdown-content em {
211
+ font-style: italic;
212
+ }
213
+
214
+ .message-bubble.markdown-content a {
215
+ color: var(--accent);
216
+ text-decoration: underline;
217
+ }
218
+
219
+ .message-bubble.markdown-content a:hover {
220
+ opacity: 0.8;
221
+ }
222
+
223
+ .select-for-viewer-btn:hover {
224
+ background-color: rgba(34, 197, 94, 0.25) !important;
225
+ border-color: rgba(34, 197, 94, 0.6) !important;
226
+ transform: translateY(-1px);
227
+ }
228
+
229
+ .select-for-viewer-btn:active {
230
+ transform: translateY(0);
231
+ }
232
+
233
+ .deselect-for-viewer-btn:hover {
234
+ background-color: rgba(239, 68, 68, 0.25) !important;
235
+ border-color: rgba(239, 68, 68, 0.6) !important;
236
+ transform: translateY(-1px);
237
+ }
238
+
239
+ .deselect-for-viewer-btn:active {
240
+ transform: translateY(0);
241
+ }
242
+
243
+ .message.selected-for-viewer {
244
+ border-left: 3px solid #22c55e;
245
+ padding-left: 4px;
246
+ }
247
+
248
+ .message-user .message-bubble {
249
+ background-color: var(--bg-message-user);
250
+ color: white;
251
+ }
252
+
253
+ .message-ai .message-bubble {
254
+ background-color: var(--bg-message-ai);
255
+ color: var(--text-main);
256
+ border: 1px solid var(--border-color);
257
+ }
258
+
259
+ .message-thoughts {
260
+ margin-top: 8px;
261
+ padding: 8px 16px;
262
+ background-color: rgba(30, 41, 59, 0.3);
263
+ border: 1px solid rgba(51, 65, 85, 0.5);
264
+ border-radius: 12px;
265
+ font-size: 13px;
266
+ color: var(--text-muted);
267
+ font-style: italic;
268
+ }
269
+
270
+ .message-thoughts-header {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 6px;
274
+ margin-bottom: 4px;
275
+ font-size: 10px;
276
+ font-weight: 700;
277
+ text-transform: uppercase;
278
+ letter-spacing: 0.1em;
279
+ color: var(--text-muted);
280
+ }
281
+
282
+ .message-image {
283
+ margin-top: 12px;
284
+ border-radius: 12px;
285
+ overflow: hidden;
286
+ border: 1px solid var(--border-color);
287
+ max-width: 100%;
288
+ }
289
+
290
+ .message-image img {
291
+ width: 100%;
292
+ height: auto;
293
+ display: block;
294
+ }
295
+
296
+ .chat-footer {
297
+ padding: 16px 24px;
298
+ background-color: var(--bg-main);
299
+ }
300
+
301
+ .chat-input-container {
302
+ max-width: 800px;
303
+ margin: 0 auto;
304
+ position: relative;
305
+ }
306
+
307
+ .chat-input-wrapper {
308
+ background-color: var(--bg-message-ai);
309
+ border: 1px solid var(--border-color);
310
+ border-radius: 16px;
311
+ padding: 8px;
312
  display: flex;
313
+ align-items: flex-end;
314
+ transition: border-color 0.2s;
315
  }
316
+
317
+ .chat-input-wrapper:focus-within {
318
+ border-color: rgba(59, 130, 246, 0.5);
319
  }
320
+
321
+ .chat-input {
322
+ flex: 1;
323
+ background: transparent;
324
+ border: none;
325
+ outline: none;
326
+ color: var(--text-main);
327
+ padding: 12px;
328
+ font-size: 15px;
329
+ resize: none;
330
+ max-height: 200px;
331
+ font-family: inherit;
332
+ }
333
+
334
+ .chat-send-btn {
335
+ background-color: var(--bg-message-user);
336
+ color: white;
337
+ border: none;
338
+ border-radius: 12px;
339
+ width: 44px;
340
+ height: 44px;
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: center;
344
+ cursor: pointer;
345
+ transition: opacity 0.2s, transform 0.1s;
346
+ }
347
+
348
+ .chat-send-btn:disabled {
349
+ opacity: 0.5;
350
+ cursor: not-allowed;
351
+ }
352
+
353
+ .chat-send-btn:active:not(:disabled) {
354
+ transform: scale(0.95);
355
+ }
356
+
357
+ .chat-input-hint {
358
+ text-align: center;
359
+ font-size: 10px;
360
+ color: var(--text-muted);
361
+ margin-top: 12px;
362
+ text-transform: uppercase;
363
+ letter-spacing: 0.1em;
364
+ }
365
+
366
+ /* Sidebar Section */
367
+ .sidebar {
368
+ width: 320px;
369
+ background-color: var(--bg-sidebar);
370
+ border-left: 1px solid var(--border-color);
371
+ display: flex;
372
+ flex-direction: column;
373
+ transition: transform 0.3s ease, width 0.3s ease;
374
+ overflow-y: auto;
375
+ }
376
+
377
+ .sidebar.hidden {
378
+ width: 0;
379
+ border-left: none;
380
+ transform: translateX(100%);
381
+ }
382
+
383
+ .sidebar-content {
384
+ padding: 24px;
385
+ display: flex;
386
+ flex-direction: column;
387
+ gap: 32px;
388
+ min-width: 320px;
389
+ }
390
+
391
+ .sidebar-section-title {
392
+ font-size: 10px;
393
  font-weight: 700;
394
+ color: var(--text-muted);
395
  text-transform: uppercase;
396
+ letter-spacing: 0.2em;
397
+ margin-bottom: 16px;
398
+ }
399
+
400
+ .status-card {
401
+ background-color: rgba(30, 41, 59, 0.4);
402
+ border: 1px solid rgba(51, 65, 85, 0.5);
403
+ border-radius: 16px;
404
+ padding: 16px;
405
+ }
406
+
407
+ .user-profile {
408
  display: flex;
409
  align-items: center;
410
+ gap: 12px;
411
+ margin-bottom: 16px;
412
  }
413
+
414
+ .user-avatar {
415
+ width: 40px;
416
+ height: 40px;
417
+ background-color: var(--bg-sidebar);
418
+ border-radius: 50%;
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ color: var(--text-muted);
423
+ border: 1px solid var(--border-color);
424
+ }
425
+
426
+ .user-info-name {
427
+ font-size: 13px;
428
+ font-weight: 700;
429
+ color: var(--text-main);
430
+ }
431
+
432
+ .user-info-role {
433
+ font-size: 10px;
434
+ color: var(--text-muted);
435
+ text-transform: uppercase;
436
+ letter-spacing: 0.1em;
437
+ }
438
+
439
+ .status-items {
440
+ border-top: 1px solid rgba(51, 65, 85, 0.5);
441
+ padding-top: 12px;
442
+ display: flex;
443
+ flex-direction: column;
444
+ gap: 8px;
445
+ }
446
+
447
+ .status-item {
448
+ display: flex;
449
+ justify-content: space-between;
450
+ font-size: 11px;
451
+ }
452
+
453
+ .status-item-label {
454
+ color: var(--text-muted);
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 8px;
458
+ }
459
+
460
+ .objective-card {
461
+ background-color: rgba(59, 130, 246, 0.05);
462
+ border: 1px solid rgba(59, 130, 246, 0.2);
463
+ border-radius: 12px;
464
+ padding: 16px;
465
+ font-size: 12px;
466
+ color: rgba(191, 219, 254, 0.8);
467
+ line-height: 1.6;
468
+ font-style: italic;
469
+ }
470
+
471
+ .model-select {
472
+ background-color: var(--bg-message-ai);
473
+ border: 1px solid var(--border-color);
474
+ color: var(--text-muted);
475
+ font-size: 12px;
476
+ padding: 6px 12px;
477
  border-radius: 8px;
478
+ outline: none;
479
+ cursor: pointer;
 
480
  }
481
+
482
+ .toggle-sidebar-btn {
483
+ background: none;
484
+ border: none;
485
+ color: var(--text-muted);
486
+ cursor: pointer;
487
+ padding: 8px;
488
+ border-radius: 8px;
489
+ transition: background-color 0.2s;
490
+ }
491
+
492
+ .toggle-sidebar-btn:hover {
493
+ background-color: var(--bg-message-ai);
494
+ }
495
+
496
+ .toggle-sidebar-btn.active {
497
+ color: var(--accent);
498
+ background-color: rgba(59, 130, 246, 0.1);
499
+ }
500
+
501
+ /* Custom Scrollbar */
502
+ ::-webkit-scrollbar { width: 6px; }
503
+ ::-webkit-scrollbar-track { background: transparent; }
504
+ ::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
505
+ ::-webkit-scrollbar-thumb:hover { background: #334155; }
506
+
507
+ /* Typing Indicator */
508
+ .typing-indicator {
509
+ display: flex;
510
+ gap: 4px;
511
+ padding: 4px 8px;
512
+ }
513
+
514
+ .dot {
515
+ width: 6px;
516
+ height: 6px;
517
+ background-color: var(--text-muted);
518
+ border-radius: 50%;
519
+ animation: typing-dot 1.4s infinite ease-in-out;
520
+ }
521
+
522
+ .dot:nth-child(1) { animation-delay: -0.32s; }
523
+ .dot:nth-child(2) { animation-delay: -0.16s; }
524
+
525
+ @keyframes typing-dot {
526
+ 0%, 80%, 100% { transform: scale(0); opacity: 0.3; }
527
+ 40% { transform: scale(1); opacity: 1; }
528
+ }
529
+
530
+ @media (max-width: 1024px) {
531
+ .sidebar {
532
+ position: fixed;
533
+ right: 0;
534
+ top: 64px;
535
+ bottom: 0;
536
+ z-index: 20;
537
+ box-shadow: -10px 0 20px rgba(0,0,0,0.5);
538
+ }
539
  }
540
  </style>
541
+ {% endblock %}
542
 
543
+ {% block content %}
544
+ <div id="app-container">
545
+ <section class="chat-section">
546
+ <header class="chat-header">
547
+ <div class="chat-header-info">
548
+ <div class="chat-header-icon">
549
+ <i class="fas fa-robot"></i>
550
+ </div>
551
+ <div>
552
+ <h1 class="chat-header-title">{{ project.title }}</h1>
553
+ <p class="chat-header-subtitle">{{ project.mode }} MODE</p>
554
+ </div>
555
  </div>
556
+
557
+ <div style="display: flex; align-items: center; gap: 16px;">
558
+ <div style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; background-color: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 8px;">
559
+ <label style="display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted); cursor: pointer;">
560
+ <input type="checkbox" id="viewer-toggle" style="cursor: pointer;">
561
+ <span>뷰어 활성화</span>
562
+ </label>
563
+ </div>
564
+ <a id="viewer-link" href="#" target="_blank" style="display: none; padding: 6px 12px; background-color: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2); border-radius: 8px; color: #22c55e; text-decoration: none; font-size: 11px; font-weight: 600;">
565
+ <i class="fas fa-external-link-alt"></i> 뷰어 열기
566
+ </a>
567
+ <select id="model-selector" class="model-select">
568
+ <!-- Models will be loaded here -->
569
+ </select>
570
+ <button id="toggle-sidebar" class="toggle-sidebar-btn active">
571
+ <i class="fas fa-columns"></i>
572
+ </button>
573
+ </div>
574
+ </header>
575
 
576
+ <main id="chat-messages" class="chat-messages">
577
+ <div id="chat-messages-inner" class="chat-messages-inner">
578
+ <!-- Messages will be rendered here -->
 
 
 
 
 
 
 
 
 
 
 
579
  </div>
580
+ </main>
581
 
582
+ <footer class="chat-footer">
583
+ <div class="chat-input-container">
584
+ <div class="chat-input-wrapper">
585
+ <textarea id="chat-input" class="chat-input" placeholder="어떤 장면그려볼까요?" rows="1"></textarea>
586
+ <button id="send-btn" class="chat-send-btn" disabled>
587
+ <i class="fas fa-paper-plane"></i>
588
+ </button>
589
+ </div>
590
+ <p class="chat-input-hint">
591
+ Enter: 전송 / Shift + Enter: 줄바꿈
592
+ </p>
593
  </div>
594
+ </footer>
595
+ </section>
596
 
597
+ <aside id="sidebar" class="sidebar">
598
+ <div class="sidebar-content">
599
+ <div>
600
+ <h3 class="sidebar-section-title">Project Status</h3>
601
+ <div class="status-card">
602
+ <div class="user-profile">
603
+ <div class="user-avatar">
604
+ <i class="fas fa-user-pen"></i>
605
+ </div>
606
+ <div>
607
+ <div class="user-info-name">{{ current_user.username }}</div>
608
+ <div class="user-info-role">Author</div>
609
+ </div>
610
+ </div>
611
+
612
+ <div id="status-items" class="status-items" style="display: none;">
613
+ <div class="status-item">
614
+ <span class="status-item-label"><i class="fas fa-map-marker-alt"></i> Location</span>
615
+ <span id="status-location" class="status-item-value">Unknown</span>
616
+ </div>
617
+ <div class="status-item">
618
+ <span class="status-item-label"><i class="fas fa-clock"></i> Time</span>
619
+ <span id="status-time" class="status-item-value">D+0000 | 00:00</span>
620
+ </div>
621
+ </div>
622
+ </div>
623
+ </div>
624
+
625
+ <div id="objective-section" style="display: none;">
626
+ <h3 class="sidebar-section-title">Current Objective</h3>
627
+ <div id="objective-card" class="objective-card">
628
+ <!-- Objective will be loaded here -->
629
+ </div>
630
+ </div>
631
+
632
+ <div id="inventory-section" style="display: none;">
633
+ <h3 class="sidebar-section-title">Contextual Items</h3>
634
+ <div id="inventory-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
635
+ <!-- Items will be loaded here -->
636
+ </div>
637
+ </div>
638
+
639
+ <div style="margin-top: auto; padding-top: 24px; border-top: 1px solid var(--border-color);">
640
+ <div style="background-color: rgba(245, 158, 11, 0.05); border: 1px solid rgba(245, 158, 11, 0.1); border-radius: 12px; padding: 16px;">
641
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
642
+ <i class="fas fa-lightbulb" style="color: #f59e0b; font-size: 14px;"></i>
643
+ <span style="font-size: 10px; font-weight: 700; color: #f59e0b; text-transform: uppercase; letter-spacing: 0.1em;">Writing Tip</span>
644
+ </div>
645
+ <p style="font-size: 11px; color: rgba(253, 230, 138, 0.6); line-height: 1.6; margin: 0;">
646
+ 특정 캐릭터의 대사나 심리 묘사를 구체적으로 요청해보세요. 에이전트가 더 풍부한 서사를 제공합니다.
647
+ </p>
648
+ </div>
649
+ </div>
650
  </div>
651
+ </aside>
652
  </div>
653
 
654
  <script>
655
+ // Vanilla JavaScript Chat Implementation
656
+ document.addEventListener('DOMContentLoaded', () => {
657
+ const chatInput = document.getElementById('chat-input');
658
+ const sendBtn = document.getElementById('send-btn');
659
+ const chatMessages = document.getElementById('chat-messages');
660
+ const chatMessagesInner = document.getElementById('chat-messages-inner');
661
+ const modelSelector = document.getElementById('model-selector');
662
+ const toggleSidebarBtn = document.getElementById('toggle-sidebar');
663
+ const sidebar = document.getElementById('sidebar');
664
+
665
+ const projectId = "{{ project.project_id }}";
666
+ const projectTitle = "{{ project.title }}";
667
+
668
+ let isTyping = false;
669
+
670
+ // --- Sidebar Management ---
671
+ toggleSidebarBtn.addEventListener('click', () => {
672
+ sidebar.classList.toggle('hidden');
673
+ toggleSidebarBtn.classList.toggle('active');
674
+ });
675
 
676
+ // --- Model Loading ---
677
+ async function loadModels() {
678
+ try {
679
+ const response = await fetch('/api/ollama/models?all=true');
680
+ const data = await response.json();
681
+ if (data.models) {
682
+ modelSelector.innerHTML = data.models.map(m =>
683
+ `<option value="${m.name}">${m.name}</option>`
684
+ ).join('');
685
+
686
+ const savedModel = localStorage.getItem('lastWriterModel');
687
+ if (savedModel) modelSelector.value = savedModel;
688
+ else if (data.models.length > 0) {
689
+ const defaultModel = data.models.find(m => m.name.includes('gemini')) || data.models[0];
690
+ modelSelector.value = defaultModel.name;
691
+ }
692
+ }
693
+ } catch (e) { console.error('Failed to load models:', e); }
694
+ }
695
 
696
+ modelSelector.addEventListener('change', (e) => {
697
+ localStorage.setItem('lastWriterModel', e.target.value);
698
+ });
699
+
700
+ // --- Markdown Rendering ---
701
+ function renderMarkdown(text) {
702
+ if (!text) return '';
703
+ try {
704
+ // marked.js가 ��드되었는지 확인
705
+ if (typeof marked !== 'undefined') {
706
+ // 연속된 공백을 보존하기 위해 먼저 변환
707
+ // 2개 이상의 연속된 공백을 &nbsp;로 변환 (단, 줄바꿈은 유지)
708
+ const preservedText = text.replace(/ {2,}/g, (match) => {
709
+ return '&nbsp;'.repeat(match.length);
710
+ });
711
+ return marked.parse(preservedText);
712
+ } else {
713
+ // marked.js가 로드되지 않은 경우 기본 텍스트 반환
714
+ // 연속된 공백 보존
715
+ return text.replace(/ {2,}/g, (match) => {
716
+ return '&nbsp;'.repeat(match.length);
717
+ }).replace(/\n/g, '<br>');
718
+ }
719
+ } catch (e) {
720
+ console.error('Markdown rendering error:', e);
721
+ // 에러 발생 시에도 공백 보존
722
+ return text.replace(/ {2,}/g, (match) => {
723
+ return '&nbsp;'.repeat(match.length);
724
+ }).replace(/\n/g, '<br>');
725
+ }
726
+ }
727
+
728
+ // --- Message Rendering ---
729
+ function createMessageElement(role, content, extra = {}, messageId = null) {
730
+ const messageDiv = document.createElement('div');
731
+ messageDiv.className = `message message-${role}`;
732
+ if (messageId) {
733
+ messageDiv.dataset.messageId = messageId;
734
+ }
735
+
736
+ // 마크다운을 HTML로 변환
737
+ const renderedContent = renderMarkdown(content);
738
+ let html = `<div class="message-bubble markdown-content">${renderedContent}</div>`;
739
+
740
+ if (extra.imageUrl) {
741
+ html += `<div class="message-image"><img src="${extra.imageUrl}" alt="Generated content"></div>`;
742
+ }
743
+
744
+ if (role === 'ai' && extra.thoughts) {
745
+ const renderedThoughts = renderMarkdown(extra.thoughts);
746
+ html += `
747
+ <div class="message-thoughts">
748
+ <div class="message-thoughts-header"><i class="fas fa-brain"></i> Internal Reflection</div>
749
+ <div class="markdown-content">${renderedThoughts}</div>
750
+ </div>`;
751
+ }
752
+
753
+ // AI 메시지에 뷰어 선택 버튼 추가 (뷰어 활성화 시에만 표시)
754
+ if (role === 'ai') {
755
+ const btnMessageId = messageId || 'temp-' + Date.now();
756
+ html += `
757
+ <div class="viewer-selection-area" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(148, 163, 184, 0.2); display: none; align-items: center; gap: 8px;">
758
+ <button class="select-for-viewer-btn" data-message-id="${btnMessageId}" style="background-color: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #22c55e; padding: 8px 16px; border-radius: 8px; font-size: 12px; cursor: pointer; font-weight: 700; transition: all 0.2s; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(34, 197, 94, 0.2);">
759
+ <i class="fas fa-eye" style="font-size: 14px;"></i> <span>뷰어에 표시</span>
760
+ </button>
761
+ <span class="viewer-selected-indicator" data-message-id="${btnMessageId}" style="display: none; color: #22c55e; font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 6px;">
762
+ <i class="fas fa-check-circle"></i> <span>뷰어에 선택됨</span>
763
+ </span>
764
+ <button class="deselect-for-viewer-btn" data-message-id="${btnMessageId}" style="display: none; background-color: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #ef4444; padding: 8px 16px; border-radius: 8px; font-size: 12px; cursor: pointer; font-weight: 700; transition: all 0.2s; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);">
765
+ <i class="fas fa-times" style="font-size: 14px;"></i> <span>선택 취소</span>
766
+ </button>
767
+ </div>`;
768
+ }
769
+
770
+ messageDiv.innerHTML = html;
771
+
772
+ // 뷰어 선택/취소 버튼 이벤트 리스너 추가 (모든 AI 메시지에)
773
+ if (role === 'ai') {
774
+ const selectBtn = messageDiv.querySelector('.select-for-viewer-btn');
775
+ const deselectBtn = messageDiv.querySelector('.deselect-for-viewer-btn');
776
+
777
+ if (selectBtn) {
778
+ const btnMessageId = selectBtn.dataset.messageId;
779
+ selectBtn.addEventListener('click', async () => {
780
+ // 메시지 내용 가져오기
781
+ const messageBubble = messageDiv.querySelector('.message-bubble');
782
+ const messageContent = messageBubble ? messageBubble.textContent.trim() : null;
783
+
784
+ // messageId가 실제 ID인지 확인하고, 없으면 메시지 내용으로 찾기
785
+ let actualMessageId = btnMessageId;
786
+ if (btnMessageId && btnMessageId.startsWith('temp-')) {
787
+ // 임시 ID인 경우, 메시지 내용으로 찾기
788
+ await selectMessageForViewer(null, messageContent);
789
+ return;
790
+ }
791
+ await selectMessageForViewer(actualMessageId, messageContent);
792
+ });
793
+ }
794
+
795
+ if (deselectBtn) {
796
+ const btnMessageId = deselectBtn.dataset.messageId;
797
+ deselectBtn.addEventListener('click', async () => {
798
+ // 실제 messageId 사용 (나중에 업데이트될 수 있음)
799
+ const actualMessageId = messageId || btnMessageId;
800
+ await deselectMessageForViewer(actualMessageId);
801
+ });
802
+ }
803
+ }
804
+
805
+ // messageId가 나중에 업데이트되는 경우를 대비
806
+ if (messageId && role === 'ai') {
807
+ const selectBtn = messageDiv.querySelector('.select-for-viewer-btn');
808
+ if (selectBtn && selectBtn.dataset.messageId.startsWith('temp-')) {
809
+ selectBtn.dataset.messageId = messageId;
810
+ const indicator = messageDiv.querySelector('.viewer-selected-indicator');
811
+ if (indicator) {
812
+ indicator.dataset.messageId = messageId;
813
+ }
814
+ }
815
+ }
816
+
817
+ return messageDiv;
818
+ }
819
+
820
+ function scrollToBottom() {
821
+ chatMessages.scrollTo({
822
+ top: chatMessages.scrollHeight,
823
+ behavior: 'smooth'
824
+ });
825
+ }
826
+
827
+ function addMessage(role, content, extra = {}, messageId = null) {
828
+ const el = createMessageElement(role, content, extra, messageId);
829
+ chatMessagesInner.appendChild(el);
830
+ scrollToBottom();
831
+ return el; // 메시지 요소 반환 (나중에 업데이트하기 위해)
832
+ }
833
+
834
+ function showTypingIndicator() {
835
+ const indicator = document.createElement('div');
836
+ indicator.id = 'typing-indicator';
837
+ indicator.className = 'message message-ai';
838
+ indicator.innerHTML = `
839
+ <div class="message-bubble">
840
+ <div class="typing-indicator">
841
+ <div class="dot"></div><div class="dot"></div><div class="dot"></div>
842
+ </div>
843
+ </div>`;
844
+ chatMessagesInner.appendChild(indicator);
845
+ scrollToBottom();
846
+ }
847
+
848
+ function hideTypingIndicator() {
849
+ const indicator = document.getElementById('typing-indicator');
850
+ if (indicator) indicator.remove();
851
+ }
852
+
853
+ // --- Chat History ---
854
+ async function loadHistory() {
855
+ try {
856
+ const response = await fetch(`/creation/api/chat/${projectId}/history`);
857
+ const data = await response.json();
858
+ if (data.messages && data.messages.length > 0) {
859
+ data.messages.forEach(m => addMessage(m.role, m.content, {}, m.id));
860
+ } else {
861
+ addMessage('ai', `반갑습니다. 소설 **"${projectTitle}"**의 창작 공간입니다. 어떤 이야기를 시작해볼까요?`);
862
+ }
863
+ } catch (e) { console.error('Failed to load history:', e); }
864
+ }
865
+
866
+ // --- Status Update ---
867
+ function updateStatus(status) {
868
+ if (!status) return;
869
+
870
+ const statusSection = document.getElementById('status-items');
871
+ statusSection.style.display = 'flex';
872
+
873
+ document.getElementById('status-location').textContent = status.location || 'Unknown';
874
+ document.getElementById('status-time').textContent = status.current_time || 'D+0000 | 00:00';
875
+
876
+ const objectiveSection = document.getElementById('objective-section');
877
+ if (status.quest_status) {
878
+ objectiveSection.style.display = 'block';
879
+ document.getElementById('objective-card').textContent = `"${status.quest_status}"`;
880
+ } else {
881
+ objectiveSection.style.display = 'none';
882
+ }
883
+
884
+ const inventorySection = document.getElementById('inventory-section');
885
+ const inventoryGrid = document.getElementById('inventory-grid');
886
+ if (status.inventory && status.inventory.length > 0) {
887
+ inventorySection.style.display = 'block';
888
+ inventoryGrid.innerHTML = status.inventory.map(item => `
889
+ <div style="background-color: rgba(30, 41, 59, 0.4); border: 1px solid var(--border-color); border-radius: 8px; padding: 8px; display: flex; align-items: center; gap: 8px;">
890
+ <i class="fas fa-cube" style="font-size: 10px; color: var(--text-muted);"></i>
891
+ <span style="font-size: 10px; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${item}</span>
892
+ </div>
893
+ `).join('');
894
+ } else {
895
+ inventorySection.style.display = 'none';
896
+ }
897
+ }
898
+
899
+ // --- Event Handlers ---
900
+ chatInput.addEventListener('input', () => {
901
+ chatInput.style.height = 'auto';
902
+ chatInput.style.height = (chatInput.scrollHeight) + 'px';
903
+ sendBtn.disabled = !chatInput.value.trim();
904
+ });
905
+
906
+ chatInput.addEventListener('keydown', (e) => {
907
+ if (e.key === 'Enter' && !e.shiftKey) {
908
+ e.preventDefault();
909
+ handleSend();
910
+ }
911
  });
912
+
913
+ async function handleSend() {
914
+ const message = chatInput.value.trim();
915
+ if (!message || isTyping) return;
916
+
917
+ chatInput.value = '';
918
+ chatInput.style.height = 'auto';
919
+ sendBtn.disabled = true;
920
+ isTyping = true;
921
+
922
+ addMessage('user', message);
923
+ showTypingIndicator();
924
+
925
+ try {
926
+ const response = await fetch(`/creation/api/chat/${projectId}`, {
927
+ method: 'POST',
928
+ headers: { 'Content-Type': 'application/json' },
929
+ body: JSON.stringify({ message, model: modelSelector.value })
930
+ });
931
+ const data = await response.json();
932
+
933
+ hideTypingIndicator();
934
+
935
+ if (response.ok) {
936
+ if (data.status) updateStatus(data.status);
937
+ // AI 응답의 메시지 ID를 가져와서 저장 (뷰어 선택용)
938
+ const aiMessageId = data.message_id || null;
939
+ const messageEl = addMessage('ai', data.reply, {
940
+ thoughts: data.hidden_thoughts,
941
+ imageUrl: data.image_url
942
+ }, aiMessageId);
943
+
944
+ // messageId가 나중에 업데이트되는 경우를 대비하여 업데이트
945
+ if (aiMessageId && messageEl) {
946
+ const selectBtn = messageEl.querySelector('.select-for-viewer-btn');
947
+ const deselectBtn = messageEl.querySelector('.deselect-for-viewer-btn');
948
+ if (selectBtn) {
949
+ selectBtn.dataset.messageId = aiMessageId;
950
+ // 이벤트 리스너 재설정
951
+ selectBtn.replaceWith(selectBtn.cloneNode(true));
952
+ const newBtn = messageEl.querySelector('.select-for-viewer-btn');
953
+ newBtn.addEventListener('click', () => {
954
+ const messageBubble = messageEl.querySelector('.message-bubble');
955
+ const messageContent = messageBubble ? messageBubble.textContent.trim() : null;
956
+ selectMessageForViewer(aiMessageId, messageContent);
957
+ });
958
+ }
959
+ if (deselectBtn) {
960
+ deselectBtn.dataset.messageId = aiMessageId;
961
+ deselectBtn.replaceWith(deselectBtn.cloneNode(true));
962
+ const newDeselectBtn = messageEl.querySelector('.deselect-for-viewer-btn');
963
+ newDeselectBtn.addEventListener('click', () => {
964
+ deselectMessageForViewer();
965
+ });
966
+ }
967
+ const indicator = messageEl.querySelector('.viewer-selected-indicator');
968
+ if (indicator) {
969
+ indicator.dataset.messageId = aiMessageId;
970
+ }
971
+
972
+ // 뷰어 활성화 상태에 따라 선택 영역 표시
973
+ const viewerToggle = document.getElementById('viewer-toggle');
974
+ if (viewerToggle && viewerToggle.checked) {
975
+ const selectionArea = messageEl.querySelector('.viewer-selection-area');
976
+ if (selectionArea) {
977
+ selectionArea.style.display = 'flex';
978
+ }
979
+ }
980
+ }
981
+ } else {
982
+ addMessage('ai', "에러가 발생했습니다: " + (data.error || 'Unknown error'));
983
+ }
984
+ } catch (e) {
985
+ hideTypingIndicator();
986
+ addMessage('ai', "서버와의 통신에 실패했습니다.");
987
+ console.error('Chat error:', e);
988
+ } finally {
989
+ isTyping = false;
990
+ sendBtn.disabled = !chatInput.value.trim();
991
+ }
992
+ }
993
+
994
+ sendBtn.addEventListener('click', handleSend);
995
+
996
+ // --- Viewer Settings ---
997
+ async function loadViewerSettings() {
998
+ try {
999
+ const response = await fetch(`/creation/api/projects/${projectId}/viewer-settings`);
1000
+ const data = await response.json();
1001
+ if (data.viewer_enabled !== undefined) {
1002
+ updateViewerUI(data);
1003
+ }
1004
+ } catch (e) {
1005
+ console.error('Failed to load viewer settings:', e);
1006
+ }
1007
+ }
1008
+
1009
+ function updateViewerUI(settings) {
1010
+ const viewerToggle = document.getElementById('viewer-toggle');
1011
+ const viewerLink = document.getElementById('viewer-link');
1012
+ if (viewerToggle) {
1013
+ viewerToggle.checked = settings.viewer_enabled || false;
1014
+ }
1015
+ if (viewerLink) {
1016
+ if (settings.viewer_enabled) {
1017
+ viewerLink.href = `/creation/viewer/${projectId}`;
1018
+ viewerLink.style.display = 'block';
1019
+ } else {
1020
+ viewerLink.style.display = 'none';
1021
+ }
1022
+ }
1023
+
1024
+ // 뷰어 선택 영역 표시/숨김
1025
+ const viewerSelectionAreas = document.querySelectorAll('.viewer-selection-area');
1026
+ if (settings.viewer_enabled) {
1027
+ viewerSelectionAreas.forEach(area => {
1028
+ area.style.display = 'flex';
1029
+ });
1030
+ } else {
1031
+ viewerSelectionAreas.forEach(area => {
1032
+ area.style.display = 'none';
1033
+ });
1034
+ }
1035
+
1036
+ // 선택된 메시지들 표시 (여러 개 가능)
1037
+ const selectedMessageIds = settings.viewer_selected_message_ids || [];
1038
+
1039
+ // 모든 메시지에서 선택 표시 제거
1040
+ document.querySelectorAll('.message').forEach(msg => {
1041
+ msg.classList.remove('selected-for-viewer');
1042
+ });
1043
+ document.querySelectorAll('.viewer-selected-indicator').forEach(ind => {
1044
+ ind.style.display = 'none';
1045
+ });
1046
+ document.querySelectorAll('.select-for-viewer-btn').forEach(btn => {
1047
+ btn.style.display = 'flex';
1048
+ });
1049
+ document.querySelectorAll('.deselect-for-viewer-btn').forEach(btn => {
1050
+ btn.style.display = 'none';
1051
+ });
1052
+
1053
+ // 선택된 메시지들 찾아서 표시
1054
+ selectedMessageIds.forEach(selectedMessageId => {
1055
+ const selectedMessage = document.querySelector(`[data-message-id="${selectedMessageId}"]`) ||
1056
+ document.querySelector(`.select-for-viewer-btn[data-message-id="${selectedMessageId}"]`)?.closest('.message');
1057
+ if (selectedMessage) {
1058
+ selectedMessage.classList.add('selected-for-viewer');
1059
+ const indicator = selectedMessage.querySelector('.viewer-selected-indicator');
1060
+ const selectButton = selectedMessage.querySelector('.select-for-viewer-btn');
1061
+ const deselectButton = selectedMessage.querySelector('.deselect-for-viewer-btn');
1062
+ if (indicator) {
1063
+ indicator.style.display = 'flex';
1064
+ indicator.dataset.messageId = selectedMessageId;
1065
+ }
1066
+ if (selectButton) selectButton.style.display = 'none';
1067
+ if (deselectButton) deselectButton.style.display = 'flex';
1068
+ }
1069
+ });
1070
+ }
1071
+
1072
+ async function toggleViewer(enabled) {
1073
+ try {
1074
+ const response = await fetch(`/creation/api/projects/${projectId}/viewer-settings`, {
1075
+ method: 'POST',
1076
+ headers: { 'Content-Type': 'application/json' },
1077
+ body: JSON.stringify({ viewer_enabled: enabled })
1078
+ });
1079
+ const data = await response.json();
1080
+ if (response.ok) {
1081
+ updateViewerUI(data);
1082
+ }
1083
+ } catch (e) {
1084
+ console.error('Failed to update viewer settings:', e);
1085
+ }
1086
+ }
1087
+
1088
+ async function deselectMessageForViewer(messageId) {
1089
+ try {
1090
+ // 현재 선택된 메시지 ID 목록 가져오기
1091
+ const currentSettings = await fetch(`/creation/api/projects/${projectId}/viewer-settings`).then(r => r.json());
1092
+ const currentSelectedIds = currentSettings.viewer_selected_message_ids || [];
1093
+
1094
+ // 해당 메시지 ID 제거
1095
+ const newSelectedIds = currentSelectedIds.filter(id => id !== messageId);
1096
+
1097
+ const response = await fetch(`/creation/api/projects/${projectId}/viewer-settings`, {
1098
+ method: 'POST',
1099
+ headers: { 'Content-Type': 'application/json' },
1100
+ body: JSON.stringify({ viewer_selected_message_ids: newSelectedIds })
1101
+ });
1102
+ const data = await response.json();
1103
+ if (response.ok) {
1104
+ updateViewerUI(data);
1105
+ } else {
1106
+ console.error('Failed to deselect message:', data.error);
1107
+ }
1108
+ } catch (e) {
1109
+ console.error('Failed to deselect message:', e);
1110
+ }
1111
+ }
1112
+
1113
+ async function selectMessageForViewer(messageId, messageContent = null) {
1114
+ try {
1115
+ // 현재 선택된 메시지 ID 목록 가져오기
1116
+ const currentSettings = await fetch(`/creation/api/projects/${projectId}/viewer-settings`).then(r => r.json());
1117
+ const currentSelectedIds = currentSettings.viewer_selected_message_ids || [];
1118
+
1119
+ // 이미 선택된 메시지인지 확인
1120
+ if (currentSelectedIds.includes(messageId)) {
1121
+ return; // 이미 선택됨
1122
+ }
1123
+
1124
+ // 새 메시지 ID 추가
1125
+ const newSelectedIds = [...currentSelectedIds, messageId];
1126
+
1127
+ const response = await fetch(`/creation/api/projects/${projectId}/viewer-settings`, {
1128
+ method: 'POST',
1129
+ headers: { 'Content-Type': 'application/json' },
1130
+ body: JSON.stringify({ viewer_selected_message_ids: newSelectedIds })
1131
+ });
1132
+ const data = await response.json();
1133
+ if (response.ok) {
1134
+ updateViewerUI(data);
1135
+ } else {
1136
+ console.error('Failed to select message:', data.error);
1137
+ }
1138
+ } catch (e) {
1139
+ console.error('Failed to select message:', e);
1140
+ }
1141
+ }
1142
+
1143
+ // 뷰어 토글 이벤트
1144
+ const viewerToggle = document.getElementById('viewer-toggle');
1145
+ if (viewerToggle) {
1146
+ viewerToggle.addEventListener('change', (e) => {
1147
+ toggleViewer(e.target.checked);
1148
+ });
1149
+ }
1150
+
1151
+ // --- Initialize ---
1152
+ loadModels();
1153
+ loadHistory();
1154
+ loadViewerSettings();
1155
+ });
1156
  </script>
1157
  {% endblock %}
 
templates/webnovels.html CHANGED
@@ -1051,6 +1051,7 @@
1051
  <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">📋 요약 보기</button>
1052
  <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">📖 내용 보기</button>
1053
  <button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">🔗 회차별 캐릭터 관계 분석</button>
 
1054
  <button class="webnovel-item-btn" onclick="viewGraphRAGByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #e91e63; color: white; margin-right: 8px;">📅 사건별 캐릭터 관계 분석</button>
1055
  <button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white; margin-right: 8px;">📊 캐릭터 관계도 시각화</button>
1056
  <button class="webnovel-item-btn" onclick="viewGraphRAGVisualizationByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #ff9800; color: white;">📊 캐릭터 관계도 시각화 (사건순)</button>
@@ -1715,6 +1716,30 @@
1715
  });
1716
  }
1717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1718
  async function viewGraphRAG(fileId, fileName) {
1719
  const modal = document.getElementById('graphRAGModal');
1720
  const title = document.getElementById('graphRAGModalTitle');
 
1051
  <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">📋 요약 보기</button>
1052
  <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">📖 내용 보기</button>
1053
  <button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">🔗 회차별 캐릭터 관계 분석</button>
1054
+ <button class="webnovel-item-btn" onclick="deleteWebnovelFile(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #dc3545; color: white; margin-right: 8px;">🗑️ 삭제</button>
1055
  <button class="webnovel-item-btn" onclick="viewGraphRAGByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #e91e63; color: white; margin-right: 8px;">📅 사건별 캐릭터 관계 분석</button>
1056
  <button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white; margin-right: 8px;">📊 캐릭터 관계도 시각화</button>
1057
  <button class="webnovel-item-btn" onclick="viewGraphRAGVisualizationByEvent(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #ff9800; color: white;">📊 캐릭터 관계도 시각화 (사건순)</button>
 
1716
  });
1717
  }
1718
 
1719
+ async function deleteWebnovelFile(fileId, fileName) {
1720
+ if (!confirm(`'${fileName}' 파일을 삭제하시겠습니까?\n연관된 모든 파일도 함께 삭제되며, 복구할 수 없습니다.`)) {
1721
+ return;
1722
+ }
1723
+
1724
+ try {
1725
+ const response = await fetch(`/api/files/${fileId}`, {
1726
+ method: 'DELETE',
1727
+ credentials: 'include'
1728
+ });
1729
+
1730
+ if (response.ok) {
1731
+ alert('파일이 성공적으로 삭제되었습니다.');
1732
+ loadWebnovels(); // 목록 새로고침
1733
+ } else {
1734
+ const data = await response.json();
1735
+ alert('삭제 실패: ' + (data.error || '알 수 없는 오류'));
1736
+ }
1737
+ } catch (error) {
1738
+ console.error('Delete error:', error);
1739
+ alert('서버 통신 실패');
1740
+ }
1741
+ }
1742
+
1743
  async function viewGraphRAG(fileId, fileName) {
1744
  const modal = document.getElementById('graphRAGModal');
1745
  const title = document.getElementById('graphRAGModalTitle');