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 +8 -0
- app/__init__.py +24 -4
- app/agent/agent.py +84 -9
- app/core/logger.py +33 -7
- app/database.py +6 -0
- app/migrations.py +32 -1
- app/routers/creation.py +164 -1
- app/routes.py +40 -46
- app/services/memory_service.py +157 -14
- app/utils/text_utils.py +23 -0
- force_update_menu.py +9 -0
- migrate_add_viewer_fields.py +53 -0
- migrate_viewer_multiple_messages.py +79 -0
- reset_menu_to_default.py +15 -0
- run.py +18 -4
- static/lib/js/babel.min.js +0 -0
- static/lib/js/framer-motion.js +0 -0
- static/lib/js/lucide.min.js +0 -0
- static/lib/js/react-dom.min.js +0 -0
- static/lib/js/react.min.js +31 -0
- static/lib/js/tailwind.js +0 -0
- templates/admin_webtoon_milestone_producer_manager.html +9 -0
- templates/creation_base.html +85 -0
- templates/novel_analysis.html +626 -0
- templates/novel_dashboard.html +80 -27
- templates/novel_viewer.html +340 -0
- templates/novel_workspace.html +1111 -144
- templates/webnovels.html +25 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
except:
|
| 352 |
pass
|
| 353 |
|
|
@@ -356,7 +370,13 @@ def create_app() -> Flask:
|
|
| 356 |
"""처리되지 않은 모든 예외 로깅"""
|
| 357 |
import traceback
|
| 358 |
err_trace = traceback.format_exc()
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
prompt = f"""
|
| 41 |
[현재 프로젝트: {project.title}]
|
| 42 |
모드: {project.mode.value}
|
| 43 |
-
|
| 44 |
-
[
|
| 45 |
- {facts_str}
|
| 46 |
|
| 47 |
-
[
|
| 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 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "설
|
| 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 생성]
|
| 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 생성]
|
| 2158 |
print(f"[Parent Chunk 생성] 디버그: result 객체 내용: {result}")
|
| 2159 |
return None
|
| 2160 |
|
| 2161 |
if not result.get('response'):
|
| 2162 |
-
print(f"[Parent Chunk 생성]
|
| 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 생성]
|
| 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 생성]
|
| 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 생성]
|
| 2231 |
print(f"[Parent Chunk 생성] 파일이 너무 크거나 모델 응답이 느릴 수 있습니다.")
|
| 2232 |
return None
|
| 2233 |
except requests.exceptions.ConnectionError:
|
| 2234 |
-
print(f"[Parent Chunk 생성]
|
| 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 생성]
|
| 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 생성]
|
| 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 생성]
|
| 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 생성]
|
| 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 생성]
|
| 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 생성]
|
| 12546 |
return jsonify({'error': error_msg}), 500
|
| 12547 |
except PermissionError:
|
| 12548 |
error_msg = f'파일 읽기 권한이 없습니다: {file.file_path}'
|
| 12549 |
-
print(f"[Parent Chunk 생성]
|
| 12550 |
return jsonify({'error': error_msg}), 500
|
| 12551 |
except Exception as e:
|
| 12552 |
error_msg = f'파일을 읽을 수 없습니다: {str(e)}'
|
| 12553 |
-
print(f"[Parent Chunk 생성]
|
| 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 생성]
|
| 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 |
-
"""
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
async def add_fact(self, project_id: str, fact: str):
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 32 |
|
| 33 |
# Flask 앱 로거 설정
|
| 34 |
-
app.logger.setLevel(logging.
|
| 35 |
app.logger.addHandler(file_handler)
|
| 36 |
app.logger.addHandler(console_handler)
|
| 37 |
|
| 38 |
# 루트 로거 설정 (모든 로거에 적용)
|
| 39 |
root_logger = logging.getLogger()
|
| 40 |
-
root_logger.setLevel(logging.
|
| 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 "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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>생성된 프로젝트가 없습니다.
|
|
|
|
| 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-
|
| 88 |
-
<h5 class="card-title text-truncate" style="max-width:
|
| 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-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 ' '.repeat(match.length);
|
| 285 |
+
});
|
| 286 |
+
return marked.parse(preservedText);
|
| 287 |
+
} else {
|
| 288 |
+
// marked.js가 로드되지 않은 경우 기본 텍스트 반환
|
| 289 |
+
return text.replace(/ {2,}/g, (match) => {
|
| 290 |
+
return ' '.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 ' '.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 "
|
| 2 |
|
| 3 |
-
{% block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
<style>
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
display: flex;
|
| 7 |
-
|
| 8 |
-
background: #f8f9fa;
|
| 9 |
overflow: hidden;
|
| 10 |
}
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
flex: 1;
|
| 13 |
display: flex;
|
| 14 |
flex-direction: column;
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
overflow-y: auto;
|
|
|
|
| 17 |
}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
display: flex;
|
| 23 |
flex-direction: column;
|
| 24 |
-
|
| 25 |
-
overflow-y: auto;
|
| 26 |
}
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
position: relative;
|
| 33 |
}
|
| 34 |
-
|
|
|
|
| 35 |
align-self: flex-end;
|
| 36 |
-
|
| 37 |
-
color: white;
|
| 38 |
-
border-bottom-right-radius: 2px;
|
| 39 |
}
|
| 40 |
-
|
|
|
|
| 41 |
align-self: flex-start;
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
display: flex;
|
| 51 |
-
|
|
|
|
| 52 |
}
|
| 53 |
-
|
| 54 |
-
|
|
|
|
| 55 |
}
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
font-weight: 700;
|
| 58 |
-
color:
|
| 59 |
text-transform: uppercase;
|
| 60 |
-
|
| 61 |
-
margin-bottom:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
display: flex;
|
| 63 |
align-items: center;
|
| 64 |
-
gap:
|
|
|
|
| 65 |
}
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
border-radius: 8px;
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
border-left: 4px solid #ffc107;
|
| 73 |
}
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
</style>
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
<
|
| 84 |
-
<
|
| 85 |
-
<
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
</div>
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
<
|
| 97 |
-
<
|
| 98 |
-
|
| 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 |
-
</
|
| 112 |
|
| 113 |
-
<
|
| 114 |
-
<
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
| 118 |
-
</
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
-
</
|
| 126 |
</div>
|
| 127 |
|
| 128 |
<script>
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
});
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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개 이상의 연속된 공백을 로 변환 (단, 줄바꿈은 유지)
|
| 708 |
+
const preservedText = text.replace(/ {2,}/g, (match) => {
|
| 709 |
+
return ' '.repeat(match.length);
|
| 710 |
+
});
|
| 711 |
+
return marked.parse(preservedText);
|
| 712 |
+
} else {
|
| 713 |
+
// marked.js가 로드되지 않은 경우 기본 텍스트 반환
|
| 714 |
+
// 연속된 공백 보존
|
| 715 |
+
return text.replace(/ {2,}/g, (match) => {
|
| 716 |
+
return ' '.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 ' '.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');
|