SOY NV AI commited on
Commit
46ab602
ยท
1 Parent(s): f5d168b

Add WebNovel story/trace views, style refinement logic, and admin menu updates

Browse files
app/agent/agent.py CHANGED
@@ -60,9 +60,8 @@ novel_agent = Agent(
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
 
@@ -98,6 +97,20 @@ async def dynamic_system_prompt(ctx: RunContext[NovelWriterDeps]) -> str:
98
 
99
  facts_str = "\n- ".join(facts) if facts else "์•„์ง ์ €์žฅ๋œ ์ •์  ์„ค์ •์ด ์—†์Šต๋‹ˆ๋‹ค."
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  # ์ดˆ๊ธฐ ์„ค์ • ์„น์…˜ ๊ตฌ์„ฑ
102
  initial_settings_section = ""
103
  if initial_settings:
@@ -115,7 +128,8 @@ async def dynamic_system_prompt(ctx: RunContext[NovelWriterDeps]) -> str:
115
 
116
  prompt = f"""
117
  [ํ˜„์žฌ ํ”„๋กœ์ ํŠธ: {project.title}]
118
- ๋ชจ๋“œ: {project.mode.value}
 
119
  {initial_settings_section}
120
  [์ •์  ์„ธ๊ณ„ ์„ค์ • ๋ฐ ์‚ฌ์‹ค๊ด€๊ณ„ (Mem0/RAG)]
121
  - {facts_str}
 
60
  "1. 'narrative' ํ•„๋“œ์—๋Š” ๋…์ž๊ฐ€ ๋ชฐ์ž…ํ•  ์ˆ˜ ์žˆ๋Š” ์†Œ์„ค ๋ณธ๋ฌธ์„ ์ž‘์„ฑํ•˜์„ธ์š”. ๋ณธ๋ฌธ ๋‚ด์— ์ƒํƒœ์ฐฝ์ด๋‚˜ ํ†ต๊ณ„ ์ˆ˜์น˜๋ฅผ ํฌํ•จํ•˜์ง€ ๋งˆ์„ธ์š”.\n"
61
  "2. 'status' ํ•„๋“œ์—๋Š” ์„œ์‚ฌ ์ „๊ฐœ์— ๋งž์ถฐ ์—…๋ฐ์ดํŠธ๋œ ์บ๋ฆญํ„ฐ์˜ ๊ฒŒ์ž„์  ์ƒํƒœ ์ •๋ณด๋ฅผ ๊ตฌ์กฐํ™”ํ•˜์—ฌ ์ž…๋ ฅํ•˜์„ธ์š”.\n"
62
  "3. 'hidden_thoughts' ํ•„๋“œ์—๋Š” ๋‹ค์Œ ์ „๊ฐœ๋ฅผ ์œ„ํ•œ ๋ณต์„ ์ด๋‚˜ ์—์ด์ „ํŠธ์˜ ์˜๋„๋ฅผ ๊ธฐ๋กํ•˜์„ธ์š”.\n"
63
+ "4. 'image_description' ํ•„๋“œ์—๋Š” ํ˜„์žฌ ์บ๋ฆญํ„ฐ์˜ ์ด๋ฆ„, ์™ธ๋ชจ, ๋ณต์žฅ, ์œ„์น˜, ์ƒํ™ฉ, ๋ถ„์œ„๊ธฐ ๋“ฑ์„ ๋ถ„์„ํ•˜์—ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์ ํ•ฉํ•œ ์˜์–ด ์„ค๋ช…์„ ์ž‘์„ฑํ•˜์„ธ์š”.\n"
64
+ "5. ๋งŒ์•ฝ 'Style Bible'์ด ์ œ๊ณต๋œ ๊ฒฝ์šฐ, ๋ฐ˜๋“œ์‹œ [๊ตฌ์ƒ -> ๋ฌธ์ฒด ๋ณ€ํ™˜ -> ์ตœ์ข… ๊ฒ€ํ† ] ๋‹จ๊ณ„๋ฅผ ๊ฑฐ์ณ ํ•ด๋‹น ๋ฌธ์ฒด๋ฅผ ์™„๋ฒฝํžˆ ๋ฐ˜์˜ํ•œ ๊ฒฐ๊ณผ๋ฌผ๋งŒ 'narrative'์— ๋‹ด์œผ์„ธ์š”. ๋‹จ์ˆœํžˆ ๋‚ด์šฉ์„ ์š”์•ฝํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, ๋ฌธ์ฒด์˜ ํŠน์ง•(์–ด๋ฏธ, ๋งํˆฌ, ๋ฌ˜์‚ฌ ๋ฐฉ์‹ ๋“ฑ)์„ ์‚ด๋ ค ํ’๋ถ€ํ•˜๊ฒŒ ์„œ์ˆ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."
 
65
  )
66
  )
67
 
 
97
 
98
  facts_str = "\n- ".join(facts) if facts else "์•„์ง ์ €์žฅ๋œ ์ •์  ์„ค์ •์ด ์—†์Šต๋‹ˆ๋‹ค."
99
 
100
+ # ๋ฌธ์ฒด ์„ค์ • ์„น์…˜ ๊ตฌ์„ฑ ๋ฐ ๋‹จ๊ณ„ ์ง€์‹œ
101
+ style_section = ""
102
+ if ctx.deps.style_content:
103
+ style_section = f"""
104
+ [์ ์šฉํ•  ๋ฌธ์ฒด ๋ฐ ์„œ์ˆ  ์ง€์นจ (Style Bible)]
105
+ {ctx.deps.style_content}
106
+
107
+ [๋ฌธ์ฒด ์ ์šฉ ์˜๋ฌด ์‚ฌํ•ญ]
108
+ ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ํŠน์ • '๋ฌธ์ฒด(Style Bible)'๋ฅผ ์ง€์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹น์‹ ์€ ๋ฐ˜๋“œ์‹œ ์œ„ ์ง€์นจ์— ๋”ฐ๋ผ ๋‹ต๋ณ€์„ ์ž‘์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
109
+ 1. ๋งํˆฌ์™€ ์–ด๋ฏธ๊ฐ€ ์ง€์นจ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.
110
+ 2. ๋ฌ˜์‚ฌ ๋ฐฉ์‹(๋น„์œ , ๋ฌธ์žฅ์˜ ๊ธธ์ด, ๋ถ„์œ„๊ธฐ)์ด ์ง€์นจ๊ณผ ๋ถ€ํ•ฉํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.
111
+ 3. ๋งŒ์•ฝ ๋‹น์‹ ์ด 'Style Bible์ด ์ œ๊ณต๋˜์ง€ ์•Š์•˜๋‹ค'๊ณ  ๋‹ต๋ณ€ํ•œ๋‹ค๋ฉด ๊ทธ๊ฒƒ์€ ์˜ค๋‹ต์ž…๋‹ˆ๋‹ค. ์œ„ ์„น์…˜์— ๋ถ„๋ช…ํžˆ ์ง€์นจ์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.
112
+ """
113
+
114
  # ์ดˆ๊ธฐ ์„ค์ • ์„น์…˜ ๊ตฌ์„ฑ
115
  initial_settings_section = ""
116
  if initial_settings:
 
128
 
129
  prompt = f"""
130
  [ํ˜„์žฌ ํ”„๋กœ์ ํŠธ: {project.title}]
131
+ ๋ชจ๋“œ: {project.mode.value if hasattr(project.mode, 'value') else project.mode}
132
+ {style_section}
133
  {initial_settings_section}
134
  [์ •์  ์„ธ๊ณ„ ์„ค์ • ๋ฐ ์‚ฌ์‹ค๊ด€๊ณ„ (Mem0/RAG)]
135
  - {facts_str}
app/agent/deps.py CHANGED
@@ -10,4 +10,5 @@ class NovelWriterDeps:
10
  mem0_client: Any # Mem0 client instance
11
  zep_client: Any # Zep client instance
12
  rag_service: Any # Interface for existing VectorDB/GraphRAG
 
13
 
 
10
  mem0_client: Any # Mem0 client instance
11
  zep_client: Any # Zep client instance
12
  rag_service: Any # Interface for existing VectorDB/GraphRAG
13
+ style_content: str = None # ์ถ”๊ฐ€: ์ ์šฉํ•  ๋ฌธ์ฒด(Style Bible) ๋‚ด์šฉ
14
 
app/database.py CHANGED
@@ -86,6 +86,7 @@ class NovelProject(db.Model):
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)
@@ -105,6 +106,7 @@ class NovelProject(db.Model):
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
@@ -470,6 +472,30 @@ class ChatbotPrompt(db.Model):
470
 
471
 
472
  # -----------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  # ์›นํˆฐ ํ”„๋กœ์ ํŠธ ์ผ์ •(์—‘์…€ ์—…๋กœ๋“œ)
474
  # -----------------------------
475
 
 
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
+ last_status = db.Column(db.Text, nullable=True) # AI๊ฐ€ ํŒ๋‹จํ•œ ๋งˆ์ง€๋ง‰ ์บ๋ฆญํ„ฐ/์„ธ๊ณ„๊ด€ ์ƒํƒœ (JSON)
90
  user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
91
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
92
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
 
106
  'custom_system_prompt': self.custom_system_prompt,
107
  'viewer_enabled': self.viewer_enabled,
108
  'viewer_selected_message_ids': self.viewer_selected_message_ids,
109
+ 'last_status': self.last_status,
110
  'user_id': self.user_id,
111
  'created_at': self.created_at.isoformat() if self.created_at else None,
112
  'updated_at': self.updated_at.isoformat() if self.updated_at else None
 
472
 
473
 
474
  # -----------------------------
475
+ class StyleAnalysis(db.Model):
476
+ """์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„ ๊ฒฐ๊ณผ (Style Bible)"""
477
+ __tablename__ = 'style_analysis'
478
+ id = db.Column(db.Integer, primary_key=True)
479
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False, unique=True)
480
+ content = db.Column(db.Text, nullable=False) # ๋ถ„์„ ๊ฒฐ๊ณผ (JSON ๊ถŒ์žฅ, ์—ฌ๊ธฐ์„  Text)
481
+ model_name = db.Column(db.String(100), nullable=True)
482
+ is_public = db.Column(db.Boolean, default=False, nullable=False) # ๊ณต๊ฐœ ์—ฌ๋ถ€ ์ถ”๊ฐ€
483
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
484
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
485
+
486
+ file = db.relationship('UploadedFile', backref=db.backref('style_analysis', uselist=False, cascade='all, delete-orphan'))
487
+
488
+ def to_dict(self):
489
+ return {
490
+ 'id': self.id,
491
+ 'file_id': self.file_id,
492
+ 'content': self.content,
493
+ 'model_name': self.model_name,
494
+ 'is_public': self.is_public,
495
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
496
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None
497
+ }
498
+
499
  # ์›นํˆฐ ํ”„๋กœ์ ํŠธ ์ผ์ •(์—‘์…€ ์—…๋กœ๋“œ)
500
  # -----------------------------
501
 
app/routers/creation.py CHANGED
@@ -7,7 +7,7 @@ import asyncio
7
  import re
8
  import json
9
  from sqlalchemy import case
10
- from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact
11
  from app.routes import inject_admin_menu as shared_inject_admin_menu
12
  from app.models.project_config import ProjectConfig, ProjectMode
13
  from app.agent.agent import novel_agent
@@ -46,7 +46,7 @@ async def generate_image_from_description(description: str) -> Optional[str]:
46
  def inject_admin_menu():
47
  return shared_inject_admin_menu()
48
 
49
- def get_deps(project_id: str, user_id: int) -> NovelWriterDeps:
50
  project_db = NovelProject.query.filter_by(project_id=project_id, user_id=user_id).first()
51
  if not project_db:
52
  return None
@@ -66,7 +66,8 @@ def get_deps(project_id: str, user_id: int) -> NovelWriterDeps:
66
  current_project=project_config,
67
  mem0_client=Mem0Service(api_key="mem0_key"),
68
  zep_client=ZepService(api_key="zep_key", api_url="http://zep:8000"),
69
- rag_service=RAGService()
 
70
  )
71
 
72
  @creation_bp.route('/webnovel', methods=['GET'])
@@ -82,7 +83,14 @@ def workspace(project_id: str):
82
  project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
83
  if not project:
84
  return "Project not found", 404
85
- return render_template('novel_workspace.html', project=project)
 
 
 
 
 
 
 
86
 
87
  def run_async(coro):
88
  """์Šค๋ ˆ๋“œ ์•ˆ์ „ํ•˜๊ฒŒ ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋„์šฐ๋ฏธ"""
@@ -135,6 +143,7 @@ def analysis(project_id: str):
135
  if file_ids:
136
  # ์šฐ์„ ์ˆœ์œ„: ์„ค์ • ํŒŒ์ผ(virtual) -> ์ฐธ์กฐ ํŒŒ์ผ
137
  if settings_file:
 
138
  parent_chunk = ParentChunk.query.filter(ParentChunk.file_id.in_(file_ids)).order_by(
139
  case((ParentChunk.file_id == settings_file.id, 0), else_=1)
140
  ).first()
@@ -621,21 +630,36 @@ def get_system_prompt(project_id: str):
621
  def chat_with_agent(project_id: str):
622
  """์—์ด์ „ํŠธ์™€ ์ฑ„ํŒ…(์ง‘ํ•„ ๋„์›€)์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค."""
623
  try:
624
- from app.database import ChatSession, ChatMessage
625
  from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
626
  from datetime import datetime
627
 
628
  data = request.get_json()
629
  message = data.get('message')
630
  model_name = data.get('model') # ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „๋‹ฌํ•œ ๋ชจ๋ธ๋ช…
 
631
 
632
- current_app.logger.info(f"[WebNovel Chat] Project: {project_id}, Message: {message}, Model: {model_name}")
633
 
634
  project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
635
  if not project:
636
  return jsonify({"error": "Project not found"}), 404
637
 
638
- deps = get_deps(project_id, current_user.id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
 
640
  # 1. ๊ธฐ์กด ์„ธ์…˜ ์ฐพ๊ธฐ ๋˜๋Š” ์ƒ์„ฑ
641
  session = ChatSession.query.filter_by(novel_project_id=project.id, user_id=current_user.id).first()
@@ -701,41 +725,119 @@ def chat_with_agent(project_id: str):
701
  agent_model = agent_model_input
702
 
703
  # 4. PydanticAI ์—์ด์ „ํŠธ ์‹คํ–‰ (history ์ „๋‹ฌ)
704
- async def _run_agent():
705
- return await novel_agent.run(message, deps=deps, model=agent_model, message_history=history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
 
707
- result = run_async(_run_agent())
708
 
 
 
 
 
 
 
 
709
  # 5. AI ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ฐ ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ถœ
710
- # result.output์€ NovelGenerationResult ์ธ์Šคํ„ด์Šค์ž„
711
- gen_result = result.output
712
- reply_text = clean_system_comments(gen_result.narrative)
713
- status_info = gen_result.status.model_dump()
714
- usage = result.usage()
715
-
716
- # 6. AI ์‘๋‹ต ์ €์žฅ (DB์—๋Š” ๋ณธ๋ฌธ ์œ„์ฃผ๋กœ ์ €์žฅ)
717
- ai_msg = ChatMessage(
718
- session_id=session.id,
719
- role='ai',
720
- content=reply_text,
721
- model_name=str(agent_model)
722
- )
723
- db.session.add(ai_msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
  # ์„ธ์…˜ ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„ ๊ฐฑ์‹ 
726
  session.updated_at = datetime.utcnow()
727
  db.session.commit()
728
 
729
  # ์ด๋ฏธ์ง€ ์„ค๋ช…์—์„œ ์ด๋ฏธ์ง€ URL ์ƒ์„ฑ (๋˜๋Š” ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์„œ๋น„์Šค ํ˜ธ์ถœ)
730
- image_url = run_async(generate_image_from_description(gen_result.image_description)) if gen_result.image_description else None
731
 
732
  return jsonify({
733
  "reply": reply_text,
734
  "status": status_info,
735
- "hidden_thoughts": gen_result.hidden_thoughts,
736
- "image_description": gen_result.image_description,
737
  "image_url": image_url,
738
- "message_id": ai_msg.id, # ๋ทฐ์–ด ์„ ํƒ์„ ์œ„ํ•œ ๋ฉ”์‹œ์ง€ ID ์ถ”๊ฐ€
 
739
  "usage": {
740
  "request_tokens": usage.request_tokens or usage.input_tokens,
741
  "response_tokens": usage.response_tokens or usage.output_tokens,
@@ -880,3 +982,33 @@ def public_viewer(project_id: str):
880
  import traceback
881
  current_app.logger.error(f"[Public Viewer Error] {str(e)}\n{traceback.format_exc()}")
882
  return f"์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}", 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import re
8
  import json
9
  from sqlalchemy import case
10
+ from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact, StyleAnalysis
11
  from app.routes import inject_admin_menu as shared_inject_admin_menu
12
  from app.models.project_config import ProjectConfig, ProjectMode
13
  from app.agent.agent import novel_agent
 
46
  def inject_admin_menu():
47
  return shared_inject_admin_menu()
48
 
49
+ def get_deps(project_id: str, user_id: int, style_content: str = None) -> NovelWriterDeps:
50
  project_db = NovelProject.query.filter_by(project_id=project_id, user_id=user_id).first()
51
  if not project_db:
52
  return None
 
66
  current_project=project_config,
67
  mem0_client=Mem0Service(api_key="mem0_key"),
68
  zep_client=ZepService(api_key="zep_key", api_url="http://zep:8000"),
69
+ rag_service=RAGService(),
70
+ style_content=style_content
71
  )
72
 
73
  @creation_bp.route('/webnovel', methods=['GET'])
 
83
  project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
84
  if not project:
85
  return "Project not found", 404
86
+
87
+ # ๊ณต๊ฐœ๋œ ๋ฌธ์ฒด ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
88
+ public_styles = StyleAnalysis.query.filter_by(is_public=True).order_by(StyleAnalysis.created_at.desc()).all()
89
+ current_app.logger.info(f"[Workspace] Found {len(public_styles)} public styles")
90
+ for s in public_styles:
91
+ current_app.logger.info(f" - Style ID: {s.id}, Public: {s.is_public}")
92
+
93
+ return render_template('novel_workspace.html', project=project, public_styles=public_styles)
94
 
95
  def run_async(coro):
96
  """์Šค๋ ˆ๋“œ ์•ˆ์ „ํ•˜๊ฒŒ ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋„์šฐ๋ฏธ"""
 
143
  if file_ids:
144
  # ์šฐ์„ ์ˆœ์œ„: ์„ค์ • ํŒŒ์ผ(virtual) -> ์ฐธ์กฐ ํŒŒ์ผ
145
  if settings_file:
146
+ print(f"DEBUG: Running updated creation.py at line 138")
147
  parent_chunk = ParentChunk.query.filter(ParentChunk.file_id.in_(file_ids)).order_by(
148
  case((ParentChunk.file_id == settings_file.id, 0), else_=1)
149
  ).first()
 
630
  def chat_with_agent(project_id: str):
631
  """์—์ด์ „ํŠธ์™€ ์ฑ„ํŒ…(์ง‘ํ•„ ๋„์›€)์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค."""
632
  try:
633
+ from app.database import ChatSession, ChatMessage, StyleAnalysis
634
  from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
635
  from datetime import datetime
636
 
637
  data = request.get_json()
638
  message = data.get('message')
639
  model_name = data.get('model') # ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „๋‹ฌํ•œ ๋ชจ๋ธ๋ช…
640
+ style_id = data.get('style_id') # ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „๋‹ฌํ•œ ๋ฌธ์ฒด ID
641
 
642
+ current_app.logger.info(f"[WebNovel Chat] Project: {project_id}, Message: {message}, Model: {model_name}, Style: {style_id}")
643
 
644
  project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first()
645
  if not project:
646
  return jsonify({"error": "Project not found"}), 404
647
 
648
+ # ๋ฌธ์ฒด ๋‚ด์šฉ ๊ฐ€์ ธ์˜ค๊ธฐ
649
+ style_content = None
650
+ if style_id:
651
+ try:
652
+ style_id_int = int(style_id)
653
+ style = StyleAnalysis.query.get(style_id_int)
654
+ if style:
655
+ style_content = style.content
656
+ current_app.logger.info(f"[Style Refinement] Style content found for ID {style_id_int} (length: {len(style_content)})")
657
+ else:
658
+ current_app.logger.warning(f"[Style Refinement] Style ID {style_id_int} not found in DB")
659
+ except (ValueError, TypeError) as e:
660
+ current_app.logger.error(f"[Style Refinement] Invalid style_id format: {style_id} - {str(e)}")
661
+
662
+ deps = get_deps(project_id, current_user.id, style_content=style_content)
663
 
664
  # 1. ๊ธฐ์กด ์„ธ์…˜ ์ฐพ๊ธฐ ๋˜๋Š” ์ƒ์„ฑ
665
  session = ChatSession.query.filter_by(novel_project_id=project.id, user_id=current_user.id).first()
 
725
  agent_model = agent_model_input
726
 
727
  # 4. PydanticAI ์—์ด์ „ํŠธ ์‹คํ–‰ (history ์ „๋‹ฌ)
728
+ async def _run_agent_with_refinement():
729
+ # 1๋‹จ๊ณ„: ๊ธฐ๋ณธ ๋‹ต๋ณ€ ์ƒ์„ฑ
730
+ res1 = await novel_agent.run(message, deps=deps, model=agent_model, message_history=history)
731
+
732
+ if style_content:
733
+ # 2๋‹จ๊ณ„: ๋ฌธ์ฒด ์ˆ˜์ • ๋‹จ๊ณ„ ์ถ”๊ฐ€ (Self-Refinement)
734
+ current_app.logger.info(f"[Style Refinement] Refining initial response using style_id: {style_id}")
735
+
736
+ # ์ฒซ ๋ฒˆ์งธ ์ƒ์„ฑ ๊ฒฐ๊ณผ(์ดˆ์•ˆ)๋ฅผ ํฌํ•จํ•œ ์ž„์‹œ ํžˆ์Šคํ† ๋ฆฌ ๊ตฌ์„ฑ
737
+ from pydantic_ai.messages import ModelRequest, ModelResponse, UserPromptPart, TextPart
738
+ refine_history = list(history)
739
+ refine_history.append(ModelRequest(parts=[UserPromptPart(content=message)]))
740
+ refine_history.append(ModelResponse(parts=[TextPart(content=res1.output.narrative)]))
741
+
742
+ # ๋ฌธ์ฒด ์ˆ˜์ •์„ ์œ„ํ•œ ๋ช…์‹œ์  ์š”์ฒญ
743
+ refine_query = (
744
+ f"### [์ ์šฉํ•  ๋ฌธ์ฒด ๋ฐ ์„œ์ˆ  ์ง€์นจ (Style Bible)]\n{style_content}\n\n"
745
+ "์œ„ 'Style Bible'์„ ์™„๋ฒฝํ•˜๊ฒŒ ๋ฐ˜์˜ํ•˜์—ฌ ์•ž์„  ๋‹ต๋ณ€ ๋‚ด์šฉ์„ '์ „๋ฉด ์ˆ˜์ •'ํ•ด ์ฃผ์„ธ์š”.\n"
746
+ "๋‹จ์ˆœํ•œ ๊ต์ •์ด ์•„๋‹ˆ๋ผ, Style Bible์—์„œ ๋ถ„์„๋œ ๋งํˆฌ, ์–ด๋ฏธ, ํ‘œํ˜„์˜ ๊ฐ•๋„, ์„œ์ˆ  ๊ธฐ๋ฒ• ๋“ฑ์„ ์‚ฌ์šฉํ•˜์—ฌ ์›์ž‘์˜ ๋А๋‚Œ์ด ๊ฐ•ํ•˜๊ฒŒ ๋‚˜๋„๋ก ์žฌ์ž‘์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n"
747
+ "์ตœ์ข…์ ์œผ๋กœ ๋ฌธ์ฒด๊ฐ€ ์ ์šฉ๋œ ๊ฒฐ๊ณผ๋ฌผ๋งŒ 'narrative' ํ•„๋“œ์— ๋‹ด์•„ ์‘๋‹ตํ•˜์„ธ์š”."
748
+ )
749
+
750
+ # ๋‘ ๋ฒˆ์งธ ํŒจ์Šค ์‹คํ–‰ (์ˆ˜์ • ๋‹จ๊ณ„)
751
+ res2 = await novel_agent.run(refine_query, deps=deps, model=agent_model, message_history=refine_history)
752
+
753
+ return res1, res2
754
+
755
+ return res1, None
756
 
757
+ result_initial, result_final = run_async(_run_agent_with_refinement())
758
 
759
+ # ์‹ค์ œ ์‚ฌ์šฉ๋œ ๋ชจ๋ธ๋ช… ์ถ”์ถœ
760
+ actual_model_display = str(agent_model)
761
+ if hasattr(agent_model, 'model_name'):
762
+ actual_model_display = agent_model.model_name
763
+ elif isinstance(agent_model, str):
764
+ actual_model_display = agent_model
765
+
766
  # 5. AI ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ฐ ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ถœ
767
+ # ๊ฒฐ๊ณผ๊ฐ€ ํ•˜๋‚˜๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ(style ๋ฏธ์ ์šฉ)
768
+ if result_final is None:
769
+ gen_result = result_initial.output
770
+ usage = result_initial.usage()
771
+
772
+ reply_text = clean_system_comments(gen_result.narrative)
773
+ status_info = gen_result.status.model_dump()
774
+
775
+ # AI ์‘๋‹ต ์ €์žฅ
776
+ ai_msg = ChatMessage(
777
+ session_id=session.id,
778
+ role='ai',
779
+ content=reply_text,
780
+ model_name=actual_model_display,
781
+ input_tokens=usage.request_tokens or usage.input_tokens,
782
+ output_tokens=usage.response_tokens or usage.output_tokens,
783
+ usage_type='user'
784
+ )
785
+ db.session.add(ai_msg)
786
+ else:
787
+ # ๊ฒฐ๊ณผ๊ฐ€ ๋‘ ๊ฐœ ์žˆ๋Š” ๊ฒฝ์šฐ(style ์ ์šฉ)
788
+ gen_result_initial = result_initial.output
789
+ usage_initial = result_initial.usage()
790
+
791
+ gen_result_final = result_final.output
792
+ usage_final = result_final.usage()
793
+
794
+ reply_text = clean_system_comments(gen_result_final.narrative)
795
+ status_info = gen_result_final.status.model_dump()
796
+
797
+ # 1. ์ดˆ์•ˆ ์ €์žฅ (system ํƒ€์ž…์œผ๋กœ)
798
+ draft_msg = ChatMessage(
799
+ session_id=session.id,
800
+ role='ai',
801
+ content=clean_system_comments(gen_result_initial.narrative),
802
+ model_name=actual_model_display,
803
+ input_tokens=usage_initial.request_tokens or usage_initial.input_tokens,
804
+ output_tokens=usage_initial.response_tokens or usage_initial.output_tokens,
805
+ usage_type='system' # ์ดˆ์•ˆ์€ ์‹œ์Šคํ…œ์šฉ์œผ๋กœ ํ‘œ์‹œ
806
+ )
807
+ db.session.add(draft_msg)
808
+
809
+ # 2. ์ตœ์ข…๋ณธ ์ €์žฅ (user ํƒ€์ž…์œผ๋กœ)
810
+ ai_msg = ChatMessage(
811
+ session_id=session.id,
812
+ role='ai',
813
+ content=reply_text,
814
+ model_name=actual_model_display,
815
+ input_tokens=usage_final.request_tokens or usage_final.input_tokens,
816
+ output_tokens=usage_final.response_tokens or usage_final.output_tokens,
817
+ usage_type='user'
818
+ )
819
+ db.session.add(ai_msg)
820
+
821
+ usage = usage_final # ์ตœ์ข… ์‚ฌ์šฉ๋Ÿ‰์œผ๋กœ ์„ค์ • (์‘๋‹ต์šฉ)
822
+
823
+ # ํ”„๋กœ์ ํŠธ์˜ ๋งˆ์ง€๋ง‰ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
824
+ project.last_status = json.dumps(status_info)
825
 
826
  # ์„ธ์…˜ ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„ ๊ฐฑ์‹ 
827
  session.updated_at = datetime.utcnow()
828
  db.session.commit()
829
 
830
  # ์ด๋ฏธ์ง€ ์„ค๋ช…์—์„œ ์ด๋ฏธ์ง€ URL ์ƒ์„ฑ (๋˜๋Š” ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์„œ๋น„์Šค ํ˜ธ์ถœ)
831
+ image_url = run_async(generate_image_from_description(gen_result_final.image_description if result_final else gen_result.image_description)) if (result_final and gen_result_final.image_description) or (not result_final and gen_result.image_description) else None
832
 
833
  return jsonify({
834
  "reply": reply_text,
835
  "status": status_info,
836
+ "hidden_thoughts": gen_result_final.hidden_thoughts if result_final else gen_result.hidden_thoughts,
837
+ "image_description": gen_result_final.image_description if result_final else gen_result.image_description,
838
  "image_url": image_url,
839
+ "message_id": ai_msg.id,
840
+ "style_applied": True if style_content else False,
841
  "usage": {
842
  "request_tokens": usage.request_tokens or usage.input_tokens,
843
  "response_tokens": usage.response_tokens or usage.output_tokens,
 
982
  import traceback
983
  current_app.logger.error(f"[Public Viewer Error] {str(e)}\n{traceback.format_exc()}")
984
  return f"์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}", 500
985
+
986
+ @creation_bp.route('/story/<project_id>', methods=['GET'])
987
+ @login_required
988
+ def story_view(project_id: str):
989
+ """์›น์†Œ์„ค ๋ณธ๋ฌธ๋งŒ ํ™•์ธํ•˜๋Š” ํŽ˜์ด์ง€"""
990
+ from app.database import ChatSession, ChatMessage
991
+ project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first_or_404()
992
+ session = ChatSession.query.filter_by(novel_project_id=project.id).first()
993
+
994
+ messages = []
995
+ if session:
996
+ # AI๊ฐ€ ์ƒ์„ฑํ•œ ๋ณธ๋ฌธ(ai role)๋งŒ ๊ฐ€์ ธ์˜ด
997
+ messages = ChatMessage.query.filter_by(session_id=session.id, role='ai').order_by(ChatMessage.created_at.asc()).all()
998
+
999
+ return render_template('novel_story_view.html', project=project, messages=messages)
1000
+
1001
+ @creation_bp.route('/trace/<project_id>', methods=['GET'])
1002
+ @login_required
1003
+ def trace_view(project_id: str):
1004
+ """์›น์†Œ์„ค ์ƒ์„ฑ์˜ ๋ชจ๋“  ๊ณผ์ •(์ถ”๋ก , ์ƒํƒœ ๋“ฑ)์„ ํ™•์ธํ•˜๋Š” ํŽ˜์ด์ง€"""
1005
+ from app.database import ChatSession, ChatMessage
1006
+ project = NovelProject.query.filter_by(project_id=project_id, user_id=current_user.id).first_or_404()
1007
+ session = ChatSession.query.filter_by(novel_project_id=project.id).first()
1008
+
1009
+ messages = []
1010
+ if session:
1011
+ # ๋ชจ๋“  ๋ฉ”์‹œ์ง€(user, ai)๋ฅผ ๊ฐ€์ ธ์˜ด
1012
+ messages = ChatMessage.query.filter_by(session_id=session.id).order_by(ChatMessage.created_at.asc()).all()
1013
+
1014
+ return render_template('novel_trace_view.html', project=project, messages=messages)
app/routes.py CHANGED
@@ -14,13 +14,14 @@ from app.database import (
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
16
  WebtoonStageKeyMapping, WebtoonMilestone, WebtoonMilestoneManager, WebtoonMilestoneProducer,
17
- Notice, NoticeRecipient, NotionScheduleCache, PhotoAlbum, Photo
18
  )
19
  from PIL import Image
20
  from app.core.config import Config
21
  import io
22
  from app.vector_db import get_vector_db
23
  from app.gemini_client import get_gemini_client
 
24
  import requests
25
  import os
26
  from datetime import datetime, timedelta
@@ -72,8 +73,11 @@ def get_default_admin_menu():
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
  {
@@ -82,6 +86,7 @@ def get_default_admin_menu():
82
  "items": [
83
  {"label": "AI ์ž‘ํ’ˆ ๊ฐœ๋ฐœ ์–ด์‹œ์Šคํ„ดํŠธ", "endpoint": "main.index"},
84
  {"label": "์›์ž‘ ์ •๋ณด", "endpoint": "main.webnovels"},
 
85
  {"label": "ํƒœ๊ทธ ์ •๋ณด", "endpoint": "main.user_tags"},
86
  {"label": "ํ”„๋กฌํ”„ํŠธ ์ •๋ณด", "endpoint": "main.user_chatbot_prompts"},
87
  ],
@@ -201,6 +206,20 @@ def check_db_schema():
201
  with db.engine.connect() as conn:
202
  conn.execute(text("ALTER TABLE photo_album ADD COLUMN representative_photo_id INTEGER REFERENCES photo(id)"))
203
  conn.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  except Exception as e:
205
  print(f"Schema check error: {e}")
206
 
@@ -283,6 +302,7 @@ def get_user_menu():
283
  "items": [
284
  {"label": "AI ์ž‘ํ’ˆ ๊ฐœ๋ฐœ ์–ด์‹œ์Šคํ„ดํŠธ", "endpoint": "main.index"},
285
  {"label": "์›์ž‘ ์ •๋ณด", "endpoint": "main.webnovels"},
 
286
  {"label": "ํƒœ๊ทธ ์ •๋ณด", "endpoint": "main.user_tags"},
287
  {"label": "ํ”„๋กฌํ”„ํŠธ ์ •๋ณด", "endpoint": "main.user_chatbot_prompts"},
288
  ],
@@ -2896,6 +2916,50 @@ def admin_messages():
2896
  """๊ด€๋ฆฌ์ž ๋ฉ”์‹œ์ง€ ํ™•์ธ ํŽ˜์ด์ง€"""
2897
  return render_template('admin_messages.html')
2898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2899
  @main_bp.route('/admin/webtoon/personal-management')
2900
  @login_required
2901
  def webtoon_personal_management():
@@ -3025,6 +3089,65 @@ def admin_webnovels():
3025
  """์›น์†Œ์„ค ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
3026
  return render_template('admin_webnovels.html')
3027
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3028
 
3029
  @main_bp.route('/admin/webtoons/project-upload')
3030
  @admin_required
@@ -6227,6 +6350,68 @@ def admin_photo_album():
6227
  albums = PhotoAlbum.query.order_by(PhotoAlbum.created_at.desc()).all()
6228
  return render_template('admin_photo_album.html', albums=albums)
6229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6230
  @main_bp.route('/admin/photo-album/create', methods=['POST'])
6231
  @admin_required
6232
  def admin_photo_album_create():
 
14
  WebtoonWBSAnalysis, WebtoonWBSJob,
15
  WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
16
  WebtoonStageKeyMapping, WebtoonMilestone, WebtoonMilestoneManager, WebtoonMilestoneProducer,
17
+ Notice, NoticeRecipient, NotionScheduleCache, PhotoAlbum, Photo, StyleAnalysis
18
  )
19
  from PIL import Image
20
  from app.core.config import Config
21
  import io
22
  from app.vector_db import get_vector_db
23
  from app.gemini_client import get_gemini_client
24
+ from app.services.style_analysis_service import StyleAnalysisService
25
  import requests
26
  import os
27
  from datetime import datetime, timedelta
 
73
  {"label": "๋ฉ”๋‰ด ๊ด€๋ฆฌ", "endpoint": "main.admin_menu", "roles": ["admin"]},
74
  {"label": "ํŒŒ์ผ ๊ด€๋ฆฌ", "endpoint": "main.admin_files"},
75
  {"label": "๋ฉ”์‹œ์ง€ ํ™•์ธ", "endpoint": "main.admin_messages"},
76
+ {"label": "์†Œ์„ค ๋ณธ๋ฌธ ๋ณด๊ธฐ", "endpoint": "main.admin_stories"},
77
+ {"label": "์ƒ์„ฑ ๊ณผ์ • ์ถ”์ ", "endpoint": "main.admin_traces"},
78
  {"label": "์œ ํ‹ธ", "endpoint": "main.admin_utils"},
79
  {"label": "์‚ฌ์ง„์ฒฉ ๊ด€๋ฆฌ", "endpoint": "main.admin_photo_album"},
80
+ {"label": "๋ฌธ์ฒด ๋ชฉ๋ก", "endpoint": "main.admin_styles"},
81
  ],
82
  },
83
  {
 
86
  "items": [
87
  {"label": "AI ์ž‘ํ’ˆ ๊ฐœ๋ฐœ ์–ด์‹œ์Šคํ„ดํŠธ", "endpoint": "main.index"},
88
  {"label": "์›์ž‘ ์ •๋ณด", "endpoint": "main.webnovels"},
89
+ {"label": "์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„", "endpoint": "main.webnovel_style_analysis"},
90
  {"label": "ํƒœ๊ทธ ์ •๋ณด", "endpoint": "main.user_tags"},
91
  {"label": "ํ”„๋กฌํ”„ํŠธ ์ •๋ณด", "endpoint": "main.user_chatbot_prompts"},
92
  ],
 
206
  with db.engine.connect() as conn:
207
  conn.execute(text("ALTER TABLE photo_album ADD COLUMN representative_photo_id INTEGER REFERENCES photo(id)"))
208
  conn.commit()
209
+
210
+ # StyleAnalysis ํ…Œ์ด๋ธ”
211
+ if 'style_analysis' not in inspector.get_table_names():
212
+ print("Creating 'style_analysis' table...")
213
+ StyleAnalysis.__table__.create(db.engine)
214
+
215
+ # StyleAnalysis ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (is_public)
216
+ columns = [c['name'] for c in inspector.get_columns('style_analysis')]
217
+ if 'is_public' not in columns:
218
+ print("Adding 'is_public' column to style_analysis table...")
219
+ with db.engine.connect() as conn:
220
+ conn.execute(text("ALTER TABLE style_analysis ADD COLUMN is_public BOOLEAN DEFAULT FALSE NOT NULL"))
221
+ conn.commit()
222
+
223
  except Exception as e:
224
  print(f"Schema check error: {e}")
225
 
 
302
  "items": [
303
  {"label": "AI ์ž‘ํ’ˆ ๊ฐœ๋ฐœ ์–ด์‹œ์Šคํ„ดํŠธ", "endpoint": "main.index"},
304
  {"label": "์›์ž‘ ์ •๋ณด", "endpoint": "main.webnovels"},
305
+ {"label": "์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„", "endpoint": "main.webnovel_style_analysis"},
306
  {"label": "ํƒœ๊ทธ ์ •๋ณด", "endpoint": "main.user_tags"},
307
  {"label": "ํ”„๋กฌํ”„ํŠธ ์ •๋ณด", "endpoint": "main.user_chatbot_prompts"},
308
  ],
 
2916
  """๊ด€๋ฆฌ์ž ๋ฉ”์‹œ์ง€ ํ™•์ธ ํŽ˜์ด์ง€"""
2917
  return render_template('admin_messages.html')
2918
 
2919
+ @main_bp.route('/admin/stories')
2920
+ @main_bp.route('/admin/stories/<project_id>')
2921
+ @admin_required
2922
+ def admin_stories(project_id=None):
2923
+ """๊ด€๋ฆฌ์ž์šฉ ์›น์†Œ์„ค ๋ณธ๋ฌธ ํ™•์ธ"""
2924
+ from app.database import NovelProject, ChatSession, ChatMessage
2925
+
2926
+ if project_id is None:
2927
+ # ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก ํ‘œ์‹œ
2928
+ projects = NovelProject.query.order_by(NovelProject.created_at.desc()).all()
2929
+ return render_template('admin_stories_list.html', projects=projects)
2930
+
2931
+ # ํŠน์ • ํ”„๋กœ์ ํŠธ ๋ณธ๋ฌธ ํ‘œ์‹œ
2932
+ project = NovelProject.query.filter_by(project_id=project_id).first_or_404()
2933
+ session = ChatSession.query.filter_by(novel_project_id=project.id).first()
2934
+
2935
+ messages = []
2936
+ if session:
2937
+ messages = ChatMessage.query.filter_by(session_id=session.id, role='ai').order_by(ChatMessage.created_at.asc()).all()
2938
+
2939
+ return render_template('novel_story_view.html', project=project, messages=messages, is_admin=True)
2940
+
2941
+ @main_bp.route('/admin/traces')
2942
+ @main_bp.route('/admin/traces/<project_id>')
2943
+ @admin_required
2944
+ def admin_traces(project_id=None):
2945
+ """๊ด€๋ฆฌ์ž์šฉ ์›น์†Œ์„ค ์ƒ์„ฑ ๊ณผ์ • ์ถ”์ """
2946
+ from app.database import NovelProject, ChatSession, ChatMessage
2947
+
2948
+ if project_id is None:
2949
+ # ํ”„๋กœ์ ํŠธ ๋ชฉ๋ก ํ‘œ์‹œ
2950
+ projects = NovelProject.query.order_by(NovelProject.created_at.desc()).all()
2951
+ return render_template('admin_traces_list.html', projects=projects)
2952
+
2953
+ # ํŠน์ • ํ”„๋กœ์ ํŠธ ์ถ”์  ์ •๋ณด ํ‘œ์‹œ
2954
+ project = NovelProject.query.filter_by(project_id=project_id).first_or_404()
2955
+ session = ChatSession.query.filter_by(novel_project_id=project.id).first()
2956
+
2957
+ messages = []
2958
+ if session:
2959
+ messages = ChatMessage.query.filter_by(session_id=session.id).order_by(ChatMessage.created_at.asc()).all()
2960
+
2961
+ return render_template('novel_trace_view.html', project=project, messages=messages, is_admin=True)
2962
+
2963
  @main_bp.route('/admin/webtoon/personal-management')
2964
  @login_required
2965
  def webtoon_personal_management():
 
3089
  """์›น์†Œ์„ค ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
3090
  return render_template('admin_webnovels.html')
3091
 
3092
+ @main_bp.route('/analysis/webnovel_style')
3093
+ @login_required
3094
+ def webnovel_style_analysis():
3095
+ """์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„ ํŽ˜์ด์ง€"""
3096
+ # ์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค ๋ชฉ๋ก ์กฐํšŒ
3097
+ files = UploadedFile.query.order_by(UploadedFile.uploaded_at.desc()).all()
3098
+
3099
+ # ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชจ๋ธ ๋ชฉ๋ก ์กฐํšŒ
3100
+ gemini = get_gemini_client()
3101
+ models = gemini.get_available_models()
3102
+
3103
+ return render_template('analysis/webnovel_style.html', files=files, models=models)
3104
+
3105
+ @main_bp.route('/api/analysis/style', methods=['POST'])
3106
+ @login_required
3107
+ def api_analyze_webnovel_style():
3108
+ """์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„ ์š”์ฒญ ์ฒ˜๋ฆฌ"""
3109
+ try:
3110
+ data = request.get_json()
3111
+ file_id = data.get('file_id')
3112
+ model_name = data.get('model_name')
3113
+ analysis_type = data.get('type') # 'all' (default), 'bible', 'summary', 'vector'
3114
+
3115
+ if not file_id:
3116
+ return jsonify({'error': 'ํŒŒ์ผ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
3117
+
3118
+ service = StyleAnalysisService()
3119
+
3120
+ if analysis_type and analysis_type != 'all':
3121
+ # ๋ถ€๋ถ„ ๋ถ„์„ ์‹คํ–‰
3122
+ result = service.analyze_part(file_id, model_name, analysis_type)
3123
+ return jsonify({'success': True, 'type': analysis_type, 'result': result})
3124
+ else:
3125
+ # ์ „์ฒด ๋ถ„์„ ์‹คํ–‰ (๊ธฐ์กด ํ˜ธํ™˜์„ฑ)
3126
+ result = service.analyze_style(file_id, model_name)
3127
+ return jsonify({'success': True, 'type': 'all', 'result': result})
3128
+
3129
+ except Exception as e:
3130
+ return jsonify({'success': False, 'error': str(e)}), 500
3131
+
3132
+ @main_bp.route('/api/analysis/style/save', methods=['POST'])
3133
+ @login_required
3134
+ def api_save_webnovel_style():
3135
+ """์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„ ๊ฒฐ๊ณผ ์ €์žฅ (Track 1)"""
3136
+ try:
3137
+ data = request.get_json()
3138
+ file_id = data.get('file_id')
3139
+ content = data.get('content')
3140
+
3141
+ if not file_id or not content:
3142
+ return jsonify({'error': 'ํ•„์ˆ˜ ๋ฐ์ดํ„ฐ ๋ˆ„๋ฝ'}), 400
3143
+
3144
+ service = StyleAnalysisService()
3145
+ service.save_style_bible(file_id, content)
3146
+
3147
+ return jsonify({'success': True})
3148
+ except Exception as e:
3149
+ return jsonify({'success': False, 'error': str(e)}), 500
3150
+
3151
 
3152
  @main_bp.route('/admin/webtoons/project-upload')
3153
  @admin_required
 
6350
  albums = PhotoAlbum.query.order_by(PhotoAlbum.created_at.desc()).all()
6351
  return render_template('admin_photo_album.html', albums=albums)
6352
 
6353
+ @main_bp.route('/admin/styles')
6354
+ @admin_required
6355
+ def admin_styles():
6356
+ """๋ฌธ์ฒด ๋ชฉ๋ก ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
6357
+ styles = StyleAnalysis.query.order_by(StyleAnalysis.created_at.desc()).all()
6358
+ return render_template('admin_styles.html', styles=styles)
6359
+
6360
+ @main_bp.route('/api/admin/styles/<int:style_id>/visibility', methods=['POST'])
6361
+ @admin_required
6362
+ def toggle_style_visibility(style_id):
6363
+ """๋ฌธ์ฒด ๊ณต๊ฐœ/๋น„๊ณต๊ฐœ ํ† ๊ธ€"""
6364
+ style = StyleAnalysis.query.get_or_404(style_id)
6365
+ data = request.get_json()
6366
+
6367
+ style.is_public = data.get('is_public', False)
6368
+ db.session.commit()
6369
+
6370
+ return jsonify({'success': True, 'is_public': style.is_public})
6371
+
6372
+ @main_bp.route('/api/admin/styles/<int:style_id>', methods=['DELETE'])
6373
+ @admin_required
6374
+ def delete_style(style_id):
6375
+ """๋ฌธ์ฒด ์‚ญ์ œ"""
6376
+ style = StyleAnalysis.query.get_or_404(style_id)
6377
+ db.session.delete(style)
6378
+ db.session.commit()
6379
+ return jsonify({'success': True})
6380
+
6381
+ @main_bp.route('/api/admin/styles/<int:style_id>', methods=['GET'])
6382
+ @admin_required
6383
+ def get_style_detail(style_id):
6384
+ """๋ฌธ์ฒด ์ƒ์„ธ ์กฐํšŒ"""
6385
+ style = StyleAnalysis.query.get_or_404(style_id)
6386
+ return jsonify({
6387
+ 'success': True,
6388
+ 'style': {
6389
+ 'id': style.id,
6390
+ 'filename': style.file.original_filename if style.file else 'ํŒŒ์ผ ์‚ญ์ œ๋จ',
6391
+ 'content': style.content,
6392
+ 'model_name': style.model_name,
6393
+ 'is_public': style.is_public,
6394
+ 'created_at': style.created_at.strftime('%Y-%m-%d %H:%M')
6395
+ }
6396
+ })
6397
+
6398
+ @main_bp.route('/api/admin/styles/<int:style_id>/edit', methods=['POST'])
6399
+ @admin_required
6400
+ def edit_style(style_id):
6401
+ """๋ฌธ์ฒด ์ˆ˜์ •"""
6402
+ style = StyleAnalysis.query.get_or_404(style_id)
6403
+ data = request.get_json()
6404
+
6405
+ if 'content' in data:
6406
+ style.content = data['content']
6407
+ if 'model_name' in data:
6408
+ style.model_name = data['model_name']
6409
+ if 'is_public' in data:
6410
+ style.is_public = data['is_public']
6411
+
6412
+ db.session.commit()
6413
+ return jsonify({'success': True})
6414
+
6415
  @main_bp.route('/admin/photo-album/create', methods=['POST'])
6416
  @admin_required
6417
  def admin_photo_album_create():
app/services/style_analysis_service.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import logging
4
+ from app.database import db, UploadedFile, StyleAnalysis, ParentChunk, DocumentChunk
5
+ from app.gemini_client import get_gemini_client
6
+ from app.vector_db import get_vector_db
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class StyleAnalysisService:
11
+ def __init__(self):
12
+ self.gemini = get_gemini_client()
13
+ self.vector_db = get_vector_db()
14
+
15
+ def analyze_style(self, file_id: int, model_name: str = None):
16
+ """
17
+ [Legacy] ์ „์ฒด ๋ถ„์„ ํ•œ๋ฒˆ์— ์‹คํ–‰
18
+ """
19
+ results = {}
20
+ results["style_bible"] = self.analyze_part(file_id, model_name, "bible")
21
+ results["summary"] = self.analyze_part(file_id, model_name, "summary")
22
+ results["vector_db_status"] = self.analyze_part(file_id, model_name, "vector")
23
+ return results
24
+
25
+ def analyze_part(self, file_id: int, model_name: str, part: str):
26
+ """
27
+ ๋ถ€๋ถ„ ๋ถ„์„ ์‹คํ–‰
28
+ part: 'bible', 'summary', 'vector'
29
+ """
30
+ file_record = UploadedFile.query.get(file_id)
31
+ if not file_record:
32
+ raise ValueError(f"File {file_id} not found")
33
+
34
+ text_content = self._read_file_content(file_record.file_path)
35
+ # ํ…์ŠคํŠธ๊ฐ€ ์—†์–ด๋„ DB ์กฐํšŒ๋Š” ์‹œ๋„ํ•ด์•ผ ํ•จ (์ด๋ฏธ ์ €์žฅ๋œ ๊ฒฝ์šฐ)
36
+
37
+ if part == "bible":
38
+ return self._generate_style_bible(file_record, text_content, model_name)
39
+ elif part == "summary":
40
+ return self._generate_summary(file_record, text_content, model_name)
41
+ elif part == "vector":
42
+ return self._process_embeddings(file_record, text_content)
43
+ else:
44
+ raise ValueError(f"Unknown analysis part: {part}")
45
+
46
+ def save_style_bible(self, file_id: int, content: str):
47
+ """Style Bible ์ˆ˜๋™ ์ €์žฅ/์ˆ˜์ •"""
48
+ analysis = StyleAnalysis.query.filter_by(file_id=file_id).first()
49
+ if analysis:
50
+ analysis.content = content
51
+ analysis.updated_at = db.func.now()
52
+ else:
53
+ analysis = StyleAnalysis(file_id=file_id, content=content)
54
+ db.session.add(analysis)
55
+
56
+ db.session.commit()
57
+ return True
58
+
59
+ def _read_file_content(self, file_path: str) -> str:
60
+ """ํŒŒ์ผ ์ฝ๊ธฐ ๋ฐ ์ „์ฒ˜๋ฆฌ"""
61
+ try:
62
+ if not os.path.exists(file_path):
63
+ file_path = os.path.join(os.getcwd(), file_path)
64
+
65
+ if not os.path.exists(file_path):
66
+ return ""
67
+
68
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
69
+ return f.read()
70
+ except Exception as e:
71
+ logger.error(f"Error reading file {file_path}: {e}")
72
+ return ""
73
+
74
+ def _generate_style_bible(self, file_record: UploadedFile, text: str, model_name: str) -> dict:
75
+ """Track 1: ๋ฌธ์ฒด ๋ถ„์„๊ฐ€ (DB ์กฐํšŒ ์šฐ์„ )"""
76
+ # 1. DB ์กฐํšŒ
77
+ existing = StyleAnalysis.query.filter_by(file_id=file_record.id).first()
78
+ if existing and existing.content:
79
+ return existing.content
80
+
81
+ # 2. ํ…์ŠคํŠธ ์—†์œผ๋ฉด ๋ถ„์„ ๋ถˆ๊ฐ€
82
+ if not text:
83
+ return "์›๋ณธ ํŒŒ์ผ ํ…์ŠคํŠธ๋ฅผ ์ฝ์„ ์ˆ˜ ์—†์–ด ๋ถ„์„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
84
+
85
+ # 3. LLM ๋ถ„์„
86
+ sample_text = text[:10000]
87
+ prompt = f"""
88
+ ๋‹ค์Œ ์›น์†Œ์„ค ํ…์ŠคํŠธ๋ฅผ ๋ถ„์„ํ•˜์—ฌ 'Style Bible'์„ ์ž‘์„ฑํ•ด์ค˜.
89
+ ๋‹ค์Œ ํ•ญ๋ชฉ๋“ค์„ ํฌํ•จํ•ด์•ผ ํ•ด:
90
+ 1. ์ž‘๊ฐ€ ํ†ค์•ค๋งค๋„ˆ (๋ถ„์œ„๊ธฐ, ์–ด์กฐ)
91
+ 2. ๋ฌธ์žฅ ํ˜ธํก ๊ธธ์ด (๋‹จ๋ฌธ/์žฅ๋ฌธ ๋น„์œจ, ๋ฆฌ๋“ฌ๊ฐ)
92
+ 3. ์ž์ฃผ ์“ฐ๋Š” ์–ด๋ฏธ๋‚˜ ๋ฌธ์ฒด์  ํŠน์ง•
93
+ 4. ๋ฌ˜์‚ฌ ๋Œ€ ๋Œ€ํ™”์˜ ๋น„์œจ (๋Œ€๋žต์ ์ธ ํผ์„ผํŠธ์™€ ํŠน์ง•)
94
+ 5. ์ฃผ์š” ํ‚ค์›Œ๋“œ๋‚˜ ๋ฐ˜๋ณต๋˜๋Š” ํ‘œํ˜„
95
+
96
+ ํ…์ŠคํŠธ:
97
+ {sample_text}
98
+ """
99
+
100
+ response = self.gemini.generate_response(prompt, model_name=model_name)
101
+ if response.get('error'):
102
+ raise Exception(response['error'])
103
+
104
+ result_text = response.get('response', '')
105
+
106
+ # 4. DB ์ €์žฅ
107
+ try:
108
+ new_analysis = StyleAnalysis(
109
+ file_id=file_record.id,
110
+ content=result_text,
111
+ model_name=model_name
112
+ )
113
+ db.session.add(new_analysis)
114
+ db.session.commit()
115
+ except Exception as e:
116
+ logger.error(f"Failed to save Style Bible: {e}")
117
+ db.session.rollback()
118
+
119
+ return result_text
120
+
121
+ def _generate_summary(self, file_record: UploadedFile, text: str, model_name: str) -> str:
122
+ """Track 3: ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ๊ฐ€ (๊ธฐ์กด ParentChunk ์กฐํšŒ)"""
123
+ # 1. DB ์กฐํšŒ (ParentChunk)
124
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_record.id).first()
125
+ if parent_chunk:
126
+ # ๊ธฐ์กด ์ •๋ณด ์กฐํ•ฉํ•˜์—ฌ ๋ฐ˜ํ™˜
127
+ summary_parts = []
128
+ if parent_chunk.world_view: summary_parts.append(f"[์„ธ๊ณ„๊ด€]\n{parent_chunk.world_view}")
129
+ if parent_chunk.characters: summary_parts.append(f"[์ธ๋ฌผ]\n{parent_chunk.characters}")
130
+ if parent_chunk.story: summary_parts.append(f"[์Šคํ† ๋ฆฌ]\n{parent_chunk.story}")
131
+ if parent_chunk.episodes: summary_parts.append(f"[์—ํ”ผ์†Œ๋“œ]\n{parent_chunk.episodes}")
132
+
133
+ if summary_parts:
134
+ return "\n\n".join(summary_parts)
135
+
136
+ # 2. ํ…์ŠคํŠธ ์—†์œผ๋ฉด ๋ถ„์„ ๋ถˆ๊ฐ€
137
+ if not text:
138
+ return "์›๋ณธ ํŒŒ์ผ ํ…์ŠคํŠธ๋ฅผ ์ฝ์„ ์ˆ˜ ์—†์–ด ์š”์•ฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."
139
+
140
+ # 3. LLM ๋ถ„์„ (๊ธฐ์กด ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑํ•˜๋˜, ParentChunk์—๋Š” ๋„ฃ์ง€ ์•Š๊ณ  ๋‹จ์ˆœ ํ…์ŠคํŠธ ๋ฐ˜ํ™˜)
141
+ # (์›ํ•œ๋‹ค๋ฉด ParentChunk์— ์ €์žฅํ•˜๋Š” ๋กœ์ง์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Œ)
142
+ sample_text = text[:15000]
143
+ prompt = f"""
144
+ ๋‹ค์Œ ์›น์†Œ์„ค์˜ ์ดˆ๋ฐ˜๋ถ€ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ์ „์ฒด์ ์ธ ์„œ์‚ฌ ํ๋ฆ„๊ณผ ์ค„๊ฑฐ๋ฆฌ๋ฅผ ์š”์•ฝํ•ด์ค˜.
145
+ ์ฃผ์š” ๋“ฑ์žฅ์ธ๋ฌผ๊ณผ ๊ทธ๋“ค์˜ ๊ด€๊ณ„, ๊ทธ๋ฆฌ๊ณ  ์‹œ์ž‘๋˜๋Š” ์‚ฌ๊ฑด์„ ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌํ•ด์ค˜.
146
+
147
+ ํ…์ŠคํŠธ:
148
+ {sample_text}
149
+ """
150
+
151
+ response = self.gemini.generate_response(prompt, model_name=model_name)
152
+ if response.get('error'):
153
+ raise Exception(response['error'])
154
+
155
+ return response.get('response', '')
156
+
157
+ def _process_embeddings(self, file_record: UploadedFile, text: str):
158
+ """Track 2: Chunking & Embedding -> Vector DB (๊ธฐ์กด ๋ฐ์ดํ„ฐ ํ™•์ธ)"""
159
+ # 1. ์ฒญํฌ ๊ฐœ์ˆ˜ ํ™•์ธ
160
+ chunk_count = DocumentChunk.query.filter_by(file_id=file_record.id).count()
161
+
162
+ if chunk_count > 0:
163
+ return f"โœ… ์ด๋ฏธ ๊ตฌ์ถ•๋จ ({chunk_count}๊ฐœ์˜ ์ฒญํฌ)"
164
+
165
+ return "โš ๏ธ ๊ตฌ์ถ•๋˜์ง€ ์•Š์Œ (์ž๋™ ์ฒ˜๋ฆฌ ๋Œ€๊ธฐ ์ค‘)"
force_update_menu.py CHANGED
@@ -55,5 +55,6 @@ if __name__ == "__main__":
55
 
56
 
57
 
 
58
 
59
 
 
55
 
56
 
57
 
58
+
59
 
60
 
migrate_add_last_status.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from pathlib import Path
4
+
5
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ฒฝ๋กœ
6
+ db_path = Path(__file__).parent / 'instance' / 'finance_analysis.db'
7
+
8
+ def migrate_database():
9
+ """novel_project ํ…Œ์ด๋ธ”์— last_status ์ปฌ๋Ÿผ ์ถ”๊ฐ€"""
10
+ if not db_path.exists():
11
+ print(f"๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค: {db_path}")
12
+ return
13
+
14
+ try:
15
+ conn = sqlite3.connect(str(db_path))
16
+ cursor = conn.cursor()
17
+
18
+ # novel_project ํ…Œ์ด๋ธ”์— last_status ์ปฌ๋Ÿผ์ด ์žˆ๋Š”์ง€ ํ™•์ธ
19
+ cursor.execute("PRAGMA table_info(novel_project)")
20
+ columns = [column[1] for column in cursor.fetchall()]
21
+
22
+ if 'last_status' in columns:
23
+ print("last_status ์ปฌ๋Ÿผ์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.")
24
+ conn.close()
25
+ return
26
+
27
+ # last_status ์ปฌ๋Ÿผ ์ถ”๊ฐ€
28
+ print("last_status ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜๋Š” ์ค‘...")
29
+ cursor.execute("ALTER TABLE novel_project ADD COLUMN last_status TEXT")
30
+ conn.commit()
31
+ print("last_status ์ปฌ๋Ÿผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
32
+
33
+ conn.close()
34
+ print("๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
35
+
36
+ except Exception as e:
37
+ print(f"์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
38
+ raise
39
+
40
+ if __name__ == '__main__':
41
+ migrate_database()
42
+
templates/admin_stories_list.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์†Œ์„ค ๋ณธ๋ฌธ ๋ชฉ๋ก - ๊ด€๋ฆฌ์ž{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container py-5">
7
+ <div class="d-flex justify-content-between align-items-center mb-4">
8
+ <h2><i class="fas fa-book me-2"></i> ์†Œ์„ค ๋ณธ๋ฌธ ๋ชฉ๋ก (๊ด€๋ฆฌ์ž)</h2>
9
+ <a href="/" class="btn btn-outline-secondary">ํ™ˆ์œผ๋กœ</a>
10
+ </div>
11
+
12
+ <div class="card shadow-sm">
13
+ <div class="card-body">
14
+ <div class="table-responsive">
15
+ <table class="table table-hover align-middle">
16
+ <thead class="table-light">
17
+ <tr>
18
+ <th>ID</th>
19
+ <th>ํ”„๋กœ์ ํŠธ ์ œ๋ชฉ</th>
20
+ <th>์ž‘์„ฑ์ž</th>
21
+ <th>์ƒ์„ฑ์ผ</th>
22
+ <th>์ƒํƒœ</th>
23
+ <th>์•ก์…˜</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ {% for p in projects %}
28
+ <tr>
29
+ <td><code>{{ p.project_id }}</code></td>
30
+ <td><strong>{{ p.title }}</strong></td>
31
+ <td>{{ p.user.username if p.user else 'Unknown' }}</td>
32
+ <td>{{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
33
+ <td>
34
+ {% if p.last_status %}
35
+ <span class="badge bg-success">์ง„ํ–‰๋จ</span>
36
+ {% else %}
37
+ <span class="badge bg-secondary">๋Œ€๊ธฐ์ค‘</span>
38
+ {% endif %}
39
+ </td>
40
+ <td>
41
+ <a href="/admin/stories/{{ p.project_id }}" class="btn btn-sm btn-primary">
42
+ <i class="fas fa-eye me-1"></i> ๋ณธ๋ฌธ ๋ณด๊ธฐ
43
+ </a>
44
+ </td>
45
+ </tr>
46
+ {% endfor %}
47
+ {% if not projects %}
48
+ <tr>
49
+ <td colspan="6" class="text-center py-4 text-muted">๋“ฑ๋ก๋œ ํ”„๋กœ์ ํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td>
50
+ </tr>
51
+ {% endif %}
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ {% endblock %}
59
+
templates/admin_styles.html ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>๋ฌธ์ฒด ๋ชฉ๋ก ๊ด€๋ฆฌ - SOYMEDIA</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --bg-primary: #ffffff;
11
+ --bg-secondary: #f8f9fa;
12
+ --text-primary: #202124;
13
+ --text-secondary: #5f6368;
14
+ --accent: #1a73e8;
15
+ --border: #dadce0;
16
+ --success: #1e8e3e;
17
+ --danger: #d93025;
18
+ }
19
+
20
+ body {
21
+ font-family: 'Inter', sans-serif;
22
+ background: var(--bg-secondary);
23
+ color: var(--text-primary);
24
+ margin: 0;
25
+ padding: 20px;
26
+ }
27
+
28
+ .container {
29
+ max-width: 1200px;
30
+ margin: 0 auto;
31
+ }
32
+
33
+ .header {
34
+ background: var(--bg-primary);
35
+ padding: 20px;
36
+ border-radius: 8px;
37
+ margin-bottom: 20px;
38
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
39
+ display: flex;
40
+ justify-content: space-between;
41
+ align-items: center;
42
+ }
43
+
44
+ .header h1 {
45
+ margin: 0;
46
+ font-size: 24px;
47
+ }
48
+
49
+ .card {
50
+ background: var(--bg-primary);
51
+ border-radius: 8px;
52
+ padding: 20px;
53
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
54
+ }
55
+
56
+ table {
57
+ width: 100%;
58
+ border-collapse: collapse;
59
+ }
60
+
61
+ th, td {
62
+ text-align: left;
63
+ padding: 12px;
64
+ border-bottom: 1px solid var(--border);
65
+ }
66
+
67
+ th {
68
+ background: var(--bg-secondary);
69
+ font-weight: 600;
70
+ }
71
+
72
+ .btn {
73
+ padding: 6px 12px;
74
+ border: none;
75
+ border-radius: 4px;
76
+ cursor: pointer;
77
+ font-size: 13px;
78
+ font-weight: 500;
79
+ transition: background 0.2s;
80
+ text-decoration: none;
81
+ }
82
+
83
+ .btn-primary {
84
+ background: var(--accent);
85
+ color: white;
86
+ }
87
+
88
+ .btn-danger {
89
+ background: var(--danger);
90
+ color: white;
91
+ }
92
+
93
+ .btn-outline {
94
+ background: transparent;
95
+ border: 1px solid var(--border);
96
+ color: var(--text-primary);
97
+ }
98
+
99
+ .btn-outline.active {
100
+ background: var(--accent);
101
+ color: white;
102
+ border-color: var(--accent);
103
+ }
104
+
105
+ .switch {
106
+ position: relative;
107
+ display: inline-block;
108
+ width: 34px;
109
+ height: 20px;
110
+ }
111
+
112
+ .switch input {
113
+ opacity: 0;
114
+ width: 0;
115
+ height: 0;
116
+ }
117
+
118
+ .slider {
119
+ position: absolute;
120
+ cursor: pointer;
121
+ top: 0;
122
+ left: 0;
123
+ right: 0;
124
+ bottom: 0;
125
+ background-color: #ccc;
126
+ transition: .4s;
127
+ border-radius: 20px;
128
+ }
129
+
130
+ .slider:before {
131
+ position: absolute;
132
+ content: "";
133
+ height: 14px;
134
+ width: 14px;
135
+ left: 3px;
136
+ bottom: 3px;
137
+ background-color: white;
138
+ transition: .4s;
139
+ border-radius: 50%;
140
+ }
141
+
142
+ input:checked + .slider {
143
+ background-color: var(--accent);
144
+ }
145
+
146
+ input:checked + .slider:before {
147
+ transform: translateX(14px);
148
+ }
149
+
150
+ /* Modal Styles */
151
+ .modal {
152
+ display: none;
153
+ position: fixed;
154
+ z-index: 1000;
155
+ left: 0;
156
+ top: 0;
157
+ width: 100%;
158
+ height: 100%;
159
+ background-color: rgba(0,0,0,0.5);
160
+ }
161
+
162
+ .modal-content {
163
+ background-color: var(--bg-primary);
164
+ margin: 5% auto;
165
+ padding: 24px;
166
+ border-radius: 8px;
167
+ width: 80%;
168
+ max-width: 800px;
169
+ max-height: 80vh;
170
+ overflow-y: auto;
171
+ position: relative;
172
+ }
173
+
174
+ .close {
175
+ position: absolute;
176
+ right: 20px;
177
+ top: 15px;
178
+ font-size: 28px;
179
+ font-weight: bold;
180
+ cursor: pointer;
181
+ color: var(--text-secondary);
182
+ }
183
+
184
+ .form-group {
185
+ margin-bottom: 20px;
186
+ }
187
+
188
+ .form-group label {
189
+ display: block;
190
+ margin-bottom: 8px;
191
+ font-weight: 600;
192
+ }
193
+
194
+ .form-control {
195
+ width: 100%;
196
+ padding: 10px;
197
+ border: 1px solid var(--border);
198
+ border-radius: 4px;
199
+ box-sizing: border-box;
200
+ font-family: inherit;
201
+ }
202
+
203
+ textarea.form-control {
204
+ min-height: 300px;
205
+ resize: vertical;
206
+ }
207
+
208
+ .modal-footer {
209
+ margin-top: 24px;
210
+ display: flex;
211
+ justify-content: flex-end;
212
+ gap: 12px;
213
+ }
214
+
215
+ .style-link {
216
+ color: var(--accent);
217
+ text-decoration: none;
218
+ cursor: pointer;
219
+ font-weight: 500;
220
+ }
221
+
222
+ .style-link:hover {
223
+ text-decoration: underline;
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div class="container">
229
+ <div class="header">
230
+ <h1>๋ฌธ์ฒด ๋ชฉ๋ก ๊ด€๋ฆฌ</h1>
231
+ <div>
232
+ <a href="/admin" class="btn btn-outline">๊ด€๋ฆฌ์ž ํ™ˆ</a>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="card">
237
+ <table>
238
+ <thead>
239
+ <tr>
240
+ <th>ID</th>
241
+ <th>ํŒŒ์ผ๋ช…</th>
242
+ <th>๋ชจ๋ธ</th>
243
+ <th>์ƒ์„ฑ์ผ</th>
244
+ <th>๊ณต๊ฐœ ์—ฌ๋ถ€</th>
245
+ <th>๊ด€๋ฆฌ</th>
246
+ </tr>
247
+ </thead>
248
+ <tbody>
249
+ {% for style in styles %}
250
+ <tr id="row-{{ style.id }}">
251
+ <td>{{ style.id }}</td>
252
+ <td>
253
+ <span class="style-link" onclick="openStyleModal({{ style.id }})">
254
+ {{ style.file.original_filename if style.file else 'ํŒŒ์ผ ์‚ญ์ œ๋จ' }}
255
+ </span>
256
+ </td>
257
+ <td>{{ style.model_name or '-' }}</td>
258
+ <td>{{ style.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
259
+ <td>
260
+ <label class="switch">
261
+ <input type="checkbox" onchange="toggleVisibility({{ style.id }}, this)" {% if style.is_public %}checked{% endif %}>
262
+ <span class="slider"></span>
263
+ </label>
264
+ <span id="status-{{ style.id }}" style="margin-left: 8px; font-size: 13px;">
265
+ {{ '๊ณต๊ฐœ' if style.is_public else '๋ฏธ๊ณต๊ฐœ' }}
266
+ </span>
267
+ </td>
268
+ <td>
269
+ <button class="btn btn-danger" onclick="deleteStyle({{ style.id }})">์‚ญ์ œ</button>
270
+ </td>
271
+ </tr>
272
+ {% else %}
273
+ <tr>
274
+ <td colspan="6" style="text-align: center; padding: 30px; color: var(--text-secondary);">
275
+ ์ƒ์„ฑ๋œ ๋ฌธ์ฒด ๋ถ„์„ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
276
+ </td>
277
+ </tr>
278
+ {% endfor %}
279
+ </tbody>
280
+ </table>
281
+ </div>
282
+ </div>
283
+
284
+ <!-- Edit Modal -->
285
+ <div id="styleModal" class="modal">
286
+ <div class="modal-content">
287
+ <span class="close" onclick="closeModal()">&times;</span>
288
+ <h2 id="modalTitle">๋ฌธ์ฒด ์ƒ์„ธ ์ •๋ณด</h2>
289
+ <form id="editForm">
290
+ <input type="hidden" id="editStyleId">
291
+ <div class="form-group">
292
+ <label for="editFilename">ํŒŒ์ผ๋ช…</label>
293
+ <input type="text" id="editFilename" class="form-control" readonly>
294
+ </div>
295
+ <div class="form-group">
296
+ <label for="editModelName">๋ชจ๋ธ๋ช…</label>
297
+ <input type="text" id="editModelName" class="form-control">
298
+ </div>
299
+ <div class="form-group">
300
+ <label for="editContent">๋ถ„์„ ๋‚ด์šฉ</label>
301
+ <textarea id="editContent" class="form-control"></textarea>
302
+ </div>
303
+ <div class="modal-footer">
304
+ <button type="button" class="btn btn-outline" onclick="closeModal()">๋‹ซ๊ธฐ</button>
305
+ <button type="submit" class="btn btn-primary">์ €์žฅํ•˜๊ธฐ</button>
306
+ </div>
307
+ </form>
308
+ </div>
309
+ </div>
310
+
311
+ <script>
312
+ const modal = document.getElementById('styleModal');
313
+ const editForm = document.getElementById('editForm');
314
+
315
+ async function openStyleModal(id) {
316
+ try {
317
+ const response = await fetch(`/api/admin/styles/${id}`);
318
+ const data = await response.json();
319
+
320
+ if (data.success) {
321
+ document.getElementById('editStyleId').value = data.style.id;
322
+ document.getElementById('editFilename').value = data.style.filename;
323
+ document.getElementById('editModelName').value = data.style.model_name || '';
324
+ document.getElementById('editContent').value = data.style.content;
325
+
326
+ modal.style.display = 'block';
327
+ } else {
328
+ alert('์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
329
+ }
330
+ } catch (e) {
331
+ console.error(e);
332
+ alert('์˜ค๋ฅ˜ ๋ฐœ์ƒ');
333
+ }
334
+ }
335
+
336
+ function closeModal() {
337
+ modal.style.display = 'none';
338
+ }
339
+
340
+ window.onclick = function(event) {
341
+ if (event.target == modal) {
342
+ closeModal();
343
+ }
344
+ }
345
+
346
+ editForm.onsubmit = async function(e) {
347
+ e.preventDefault();
348
+ const id = document.getElementById('editStyleId').value;
349
+ const content = document.getElementById('editContent').value;
350
+ const modelName = document.getElementById('editModelName').value;
351
+
352
+ try {
353
+ const response = await fetch(`/api/admin/styles/${id}/edit`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({
357
+ content: content,
358
+ model_name: modelName
359
+ })
360
+ });
361
+
362
+ const result = await response.json();
363
+ if (result.success) {
364
+ alert('์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
365
+ location.reload(); // ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹ ์„ ์œ„ํ•ด ๋ฆฌ๋กœ๋“œ
366
+ } else {
367
+ alert('์ €์žฅ ์‹คํŒจ');
368
+ }
369
+ } catch (e) {
370
+ console.error(e);
371
+ alert('์˜ค๋ฅ˜ ๋ฐœ์ƒ');
372
+ }
373
+ }
374
+
375
+ async function toggleVisibility(id, checkbox) {
376
+ const isPublic = checkbox.checked;
377
+ const statusSpan = document.getElementById(`status-${id}`);
378
+
379
+ try {
380
+ const response = await fetch(`/api/admin/styles/${id}/visibility`, {
381
+ method: 'POST',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ body: JSON.stringify({ is_public: isPublic })
384
+ });
385
+
386
+ const result = await response.json();
387
+ if (result.success) {
388
+ statusSpan.textContent = isPublic ? '๊ณต๊ฐœ' : '๋ฏธ๊ณต๊ฐœ';
389
+ } else {
390
+ alert('๋ณ€๊ฒฝ ์‹คํŒจ');
391
+ checkbox.checked = !isPublic; // ๋˜๋Œ๋ฆฌ๊ธฐ
392
+ }
393
+ } catch (e) {
394
+ console.error(e);
395
+ alert('์˜ค๋ฅ˜ ๋ฐœ์ƒ');
396
+ checkbox.checked = !isPublic;
397
+ }
398
+ }
399
+
400
+ async function deleteStyle(id) {
401
+ if (!confirm('์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ๋ณต๊ตฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')) return;
402
+
403
+ try {
404
+ const response = await fetch(`/api/admin/styles/${id}`, {
405
+ method: 'DELETE'
406
+ });
407
+
408
+ if (response.ok) {
409
+ document.getElementById(`row-${id}`).remove();
410
+ } else {
411
+ alert('์‚ญ์ œ ์‹คํŒจ');
412
+ }
413
+ } catch (e) {
414
+ console.error(e);
415
+ alert('์˜ค๋ฅ˜ ๋ฐœ์ƒ');
416
+ }
417
+ }
418
+ </script>
419
+ </body>
420
+ </html>
421
+
templates/admin_traces_list.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์ƒ์„ฑ ๊ณผ์ • ์ถ”์  ๋ชฉ๋ก - ๊ด€๋ฆฌ์ž{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container py-5">
7
+ <div class="d-flex justify-content-between align-items-center mb-4">
8
+ <h2><i class="fas fa-history me-2"></i> ์ƒ์„ฑ ๊ณผ์ • ์ถ”์  ๋ชฉ๋ก (๊ด€๋ฆฌ์ž)</h2>
9
+ <a href="/" class="btn btn-outline-secondary">ํ™ˆ์œผ๋กœ</a>
10
+ </div>
11
+
12
+ <div class="card shadow-sm">
13
+ <div class="card-body">
14
+ <div class="table-responsive">
15
+ <table class="table table-hover align-middle">
16
+ <thead class="table-light">
17
+ <tr>
18
+ <th>ID</th>
19
+ <th>ํ”„๋กœ์ ํŠธ ์ œ๋ชฉ</th>
20
+ <th>์ž‘์„ฑ์ž</th>
21
+ <th>์ƒ์„ฑ์ผ</th>
22
+ <th>์ง„ํ–‰ ๋กœ๊ทธ</th>
23
+ <th>์•ก์…˜</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ {% for p in projects %}
28
+ <tr>
29
+ <td><code>{{ p.project_id }}</code></td>
30
+ <td><strong>{{ p.title }}</strong></td>
31
+ <td>{{ p.user.username if p.user else 'Unknown' }}</td>
32
+ <td>{{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
33
+ <td>
34
+ {% if p.last_status %}
35
+ <small class="text-muted">{{ p.last_status[:50] }}...</small>
36
+ {% else %}
37
+ -
38
+ {% endif %}
39
+ </td>
40
+ <td>
41
+ <a href="/admin/traces/{{ p.project_id }}" class="btn btn-sm btn-warning">
42
+ <i class="fas fa-search me-1"></i> ๊ณผ์ • ์ถ”์ 
43
+ </a>
44
+ </td>
45
+ </tr>
46
+ {% endfor %}
47
+ {% if not projects %}
48
+ <tr>
49
+ <td colspan="6" class="text-center py-4 text-muted">๋“ฑ๋ก๋œ ํ”„๋กœ์ ํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td>
50
+ </tr>
51
+ {% endif %}
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ {% endblock %}
59
+
templates/admin_webtoon_milestone_producer_manager.html CHANGED
@@ -351,3 +351,4 @@
351
 
352
 
353
 
 
 
351
 
352
 
353
 
354
+
templates/analysis/webnovel_style.html ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„ - SOYMEDIA</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --bg-primary: #ffffff;
11
+ --bg-secondary: #f8f9fa;
12
+ --text-primary: #202124;
13
+ --text-secondary: #5f6368;
14
+ --accent: #1a73e8;
15
+ --border: #dadce0;
16
+ --success: #1e8e3e;
17
+ --warning: #f9ab00;
18
+ }
19
+
20
+ body {
21
+ font-family: 'Inter', sans-serif;
22
+ background: var(--bg-secondary);
23
+ color: var(--text-primary);
24
+ margin: 0;
25
+ padding: 20px;
26
+ }
27
+
28
+ .container {
29
+ max-width: 1200px;
30
+ margin: 0 auto;
31
+ }
32
+
33
+ .header {
34
+ background: var(--bg-primary);
35
+ padding: 20px;
36
+ border-radius: 8px;
37
+ margin-bottom: 20px;
38
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
39
+ display: flex;
40
+ justify-content: space-between;
41
+ align-items: center;
42
+ }
43
+
44
+ .header h1 {
45
+ margin: 0;
46
+ font-size: 24px;
47
+ }
48
+
49
+ .card {
50
+ background: var(--bg-primary);
51
+ border-radius: 8px;
52
+ padding: 20px;
53
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
54
+ margin-bottom: 20px;
55
+ }
56
+
57
+ table {
58
+ width: 100%;
59
+ border-collapse: collapse;
60
+ }
61
+
62
+ th, td {
63
+ text-align: left;
64
+ padding: 12px;
65
+ border-bottom: 1px solid var(--border);
66
+ }
67
+
68
+ th {
69
+ background: var(--bg-secondary);
70
+ font-weight: 600;
71
+ }
72
+
73
+ .btn {
74
+ padding: 8px 16px;
75
+ border: none;
76
+ border-radius: 4px;
77
+ cursor: pointer;
78
+ font-size: 14px;
79
+ font-weight: 500;
80
+ transition: background 0.2s;
81
+ }
82
+
83
+ .btn-primary {
84
+ background: var(--accent);
85
+ color: white;
86
+ }
87
+
88
+ .btn-primary:hover {
89
+ background: #1557b0;
90
+ }
91
+
92
+ .btn-secondary {
93
+ background: #f1f3f4;
94
+ color: var(--text-primary);
95
+ border: 1px solid var(--border);
96
+ }
97
+
98
+ .form-group {
99
+ margin-bottom: 15px;
100
+ }
101
+
102
+ select {
103
+ padding: 8px;
104
+ border-radius: 4px;
105
+ border: 1px solid var(--border);
106
+ width: 200px;
107
+ }
108
+
109
+ .result-area {
110
+ margin-top: 20px;
111
+ padding: 20px;
112
+ background: #f1f3f4;
113
+ border-radius: 8px;
114
+ display: none;
115
+ }
116
+
117
+ .log-area {
118
+ font-family: monospace;
119
+ background: #202124;
120
+ color: #e8eaed;
121
+ padding: 15px;
122
+ border-radius: 4px;
123
+ margin-bottom: 20px;
124
+ max-height: 200px;
125
+ overflow-y: auto;
126
+ }
127
+
128
+ .log-item {
129
+ margin-bottom: 5px;
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 8px;
133
+ }
134
+
135
+ .status-icon {
136
+ width: 16px;
137
+ height: 16px;
138
+ display: inline-block;
139
+ }
140
+
141
+ .status-pending::before { content: "โšช"; }
142
+ .status-loading::before { content: "โณ"; }
143
+ .status-success::before { content: "โœ…"; }
144
+ .status-error::before { content: "โŒ"; }
145
+
146
+ /* Mermaid Diagram Style */
147
+ .diagram-container {
148
+ margin-bottom: 30px;
149
+ overflow-x: auto;
150
+ }
151
+
152
+ /* Editable Content */
153
+ .editable-content {
154
+ background: white;
155
+ padding: 15px;
156
+ border-radius: 4px;
157
+ margin-bottom: 15px;
158
+ border: 1px solid var(--border);
159
+ min-height: 200px;
160
+ white-space: pre-wrap;
161
+ }
162
+
163
+ .editable-content:focus {
164
+ outline: 2px solid var(--accent);
165
+ }
166
+ </style>
167
+ <!-- Mermaid.js for diagram -->
168
+ <script type="module">
169
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
170
+ mermaid.initialize({ startOnLoad: true });
171
+ </script>
172
+ </head>
173
+ <body>
174
+ <div class="container">
175
+ <div class="header">
176
+ <h1>์›น์†Œ์„ค ๋ฌธ์ฒด ๋ถ„์„</h1>
177
+ <div>
178
+ <a href="/" class="btn">ํ™ˆ์œผ๋กœ</a>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Workflow Diagram -->
183
+ <div class="card diagram-container">
184
+ <h3>๋ถ„์„ ์›Œํฌํ”Œ๋กœ์šฐ</h3>
185
+ <pre class="mermaid">
186
+ graph LR
187
+ A[๐Ÿ“‚ ์›น์†Œ์„ค ์›๋ฌธ] --> B(ํ…์ŠคํŠธ ์ „์ฒ˜๋ฆฌ)
188
+ subgraph "Track 1: ๋ฌธ์ฒด ๋ถ„์„"
189
+ B --> C1{๐Ÿค– LLM: ๋ฌธ์ฒด ๋ถ„์„๊ฐ€}
190
+ C1 --> D1[๐Ÿ“œ Style Bible]
191
+ end
192
+ subgraph "Track 2: ์ €์žฅ"
193
+ B --> C2(Chunking & Embedding)
194
+ C2 --> D2[(๐Ÿ—„๏ธ Vector DB)]
195
+ end
196
+ subgraph "Track 3: ์š”์•ฝ"
197
+ B --> C3{๐Ÿค– LLM: ์š”์•ฝ๊ฐ€}
198
+ C3 --> D3[๐Ÿ—ƒ๏ธ ์ค„๊ฑฐ๋ฆฌ DB]
199
+ end
200
+ </pre>
201
+ <p style="font-size: 0.9em; color: var(--text-secondary);">* Track 2(์ €์žฅ)์™€ Track 3(์š”์•ฝ)์€ <a href="/admin/webnovels">์›น์†Œ์„ค ๊ด€๋ฆฌ</a> ๋ฉ”๋‰ด์˜ ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ์ฐธ์กฐํ•ฉ๋‹ˆ๋‹ค.</p>
202
+ </div>
203
+
204
+ <div class="card">
205
+ <h3>๋ถ„์„ ์„ค์ •</h3>
206
+ <div class="form-group">
207
+ <label for="aiModel">์‚ฌ์šฉํ•  AI ๋ชจ๋ธ:</label>
208
+ <select id="aiModel">
209
+ {% for model in models %}
210
+ <option value="{{ model }}" {% if 'pro' in model %}selected{% endif %}>{{ model }}</option>
211
+ {% endfor %}
212
+ </select>
213
+ </div>
214
+ </div>
215
+
216
+ <div class="card">
217
+ <h3>์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค ๋ชฉ๋ก</h3>
218
+ <table>
219
+ <thead>
220
+ <tr>
221
+ <th>ID</th>
222
+ <th>์ œ๋ชฉ</th>
223
+ <th>ํŒŒ์ผ๋ช…</th>
224
+ <th>์—…๋กœ๋“œ ์ผ์‹œ</th>
225
+ <th>์ž‘์—…</th>
226
+ </tr>
227
+ </thead>
228
+ <tbody>
229
+ {% for file in files %}
230
+ <tr>
231
+ <td>{{ file.id }}</td>
232
+ <td>{{ file.filename }}</td>
233
+ <td>{{ file.original_filename }}</td>
234
+ <td>{{ file.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
235
+ <td>
236
+ <button class="btn btn-primary" onclick="startAnalysis({{ file.id }})">๋ถ„์„ ํ˜„ํ™ฉ ํ™•์ธ / ์‹คํ–‰</button>
237
+ </td>
238
+ </tr>
239
+ {% endfor %}
240
+ </tbody>
241
+ </table>
242
+ </div>
243
+
244
+ <div id="analysisResult" class="card result-area">
245
+ <h3>๋ถ„์„ ๊ฒฐ๊ณผ ๋ฐ ๊ด€๋ฆฌ</h3>
246
+ <div id="logArea" class="log-area">
247
+ <div id="logBible" class="log-item status-pending">Track 1: Style Bible ์ƒํƒœ ํ™•์ธ ์ค‘...</div>
248
+ <div id="logSummary" class="log-item status-pending">Track 3: ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ ๋ฐ์ดํ„ฐ ํ™•์ธ ์ค‘...</div>
249
+ <div id="logVector" class="log-item status-pending">Track 2: Vector DB ๊ตฌ์ถ• ์ƒํƒœ ํ™•์ธ ์ค‘...</div>
250
+ </div>
251
+
252
+ <div id="resultContent"></div>
253
+ </div>
254
+ </div>
255
+
256
+ <script>
257
+ async function callApi(fileId, modelName, type) {
258
+ const response = await fetch('/api/analysis/style', {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({
262
+ file_id: fileId,
263
+ model_name: modelName,
264
+ type: type
265
+ })
266
+ });
267
+ return await response.json();
268
+ }
269
+
270
+ async function saveStyleBible(fileId) {
271
+ const content = document.getElementById('bibleContent').value;
272
+ if(!content) {
273
+ alert('์ €์žฅํ•  ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค.');
274
+ return;
275
+ }
276
+
277
+ if(!confirm('Style Bible ๋‚ด์šฉ์„ ์ €์žฅํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) return;
278
+
279
+ try {
280
+ const response = await fetch('/api/analysis/style/save', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({
284
+ file_id: fileId,
285
+ content: content
286
+ })
287
+ });
288
+ const result = await response.json();
289
+ if(result.success) {
290
+ alert('์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
291
+ } else {
292
+ alert('์ €์žฅ ์‹คํŒจ: ' + result.error);
293
+ }
294
+ } catch(e) {
295
+ alert('์˜ค๋ฅ˜ ๋ฐœ์ƒ: ' + e.message);
296
+ }
297
+ }
298
+
299
+ function updateLog(elementId, status, text) {
300
+ const el = document.getElementById(elementId);
301
+ el.className = `log-item status-${status}`;
302
+ el.textContent = text;
303
+ }
304
+
305
+ async function startAnalysis(fileId) {
306
+ const modelName = document.getElementById('aiModel').value;
307
+ const resultArea = document.getElementById('analysisResult');
308
+ const content = document.getElementById('resultContent');
309
+
310
+ resultArea.style.display = 'block';
311
+ content.innerHTML = ''; // ์ดˆ๊ธฐํ™”
312
+
313
+ // ์ƒํƒœ ์ดˆ๊ธฐํ™”
314
+ updateLog('logBible', 'pending', 'Track 1: Style Bible ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘...');
315
+ updateLog('logSummary', 'pending', 'Track 3: ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘...');
316
+ updateLog('logVector', 'pending', 'Track 2: Vector DB ์ƒํƒœ ์กฐํšŒ ์ค‘...');
317
+
318
+ try {
319
+ // 1. Style Bible (Track 1)
320
+ updateLog('logBible', 'loading', 'Track 1: Style Bible ์ฒ˜๋ฆฌ ์ค‘... (DB์กฐํšŒ or LLM์ƒ์„ฑ)');
321
+ const bibleRes = await callApi(fileId, modelName, 'bible');
322
+
323
+ let bibleHtml = '';
324
+ if (bibleRes.success) {
325
+ updateLog('logBible', 'success', 'Track 1: Style Bible ์ค€๋น„ ์™„๋ฃŒ');
326
+ bibleHtml = `
327
+ <h4>[Track 1] Style Bible (ํŽธ์ง‘ ๊ฐ€๋Šฅ)</h4>
328
+ <div style="margin-bottom: 10px;">
329
+ <textarea id="bibleContent" class="editable-content" style="width:100%; height:300px;">${bibleRes.result}</textarea>
330
+ <button class="btn btn-primary" onclick="saveStyleBible(${fileId})">์ €์žฅํ•˜๊ธฐ</button>
331
+ </div>
332
+ `;
333
+ } else {
334
+ updateLog('logBible', 'error', `Track 1 ์‹คํŒจ: ${bibleRes.error}`);
335
+ }
336
+
337
+ // 2. Summary (Track 3)
338
+ updateLog('logSummary', 'loading', 'Track 3: ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ ์กฐํšŒ ์ค‘...');
339
+ const summaryRes = await callApi(fileId, modelName, 'summary');
340
+
341
+ let summaryHtml = '';
342
+ if (summaryRes.success) {
343
+ updateLog('logSummary', 'success', 'Track 3: ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ ํ™•์ธ๋จ');
344
+ summaryHtml = `
345
+ <h4>[Track 3] ์ค„๊ฑฐ๋ฆฌ ์š”์•ฝ (์ฐธ์กฐ)</h4>
346
+ <div style="background: white; padding: 15px; border-radius: 4px; margin-bottom: 15px; max-height: 200px; overflow-y: auto;">
347
+ ${summaryRes.result.replace(/\n/g, '<br>')}
348
+ </div>
349
+ <p style="font-size:0.9em; color:#666;">* ์ˆ˜์ •์€ <a href="/admin/webnovels">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>์—์„œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.</p>
350
+ `;
351
+ } else {
352
+ updateLog('logSummary', 'error', `Track 3 ์‹คํŒจ: ${summaryRes.error}`);
353
+ }
354
+
355
+ // 3. Vector DB (Track 2)
356
+ updateLog('logVector', 'loading', 'Track 2: Vector DB ์ƒํƒœ ํ™•์ธ ์ค‘...');
357
+ const vectorRes = await callApi(fileId, modelName, 'vector');
358
+
359
+ let vectorHtml = '';
360
+ if (vectorRes.success) {
361
+ updateLog('logVector', 'success', `Track 2: ${vectorRes.result}`);
362
+ vectorHtml = `
363
+ <h4>[Track 2] Vector DB ์ƒํƒœ</h4>
364
+ <div style="background: white; padding: 15px; border-radius: 4px;">
365
+ ${vectorRes.result}
366
+ </div>
367
+ `;
368
+ } else {
369
+ updateLog('logVector', 'error', `Track 2 ์‹คํŒจ: ${vectorRes.error}`);
370
+ }
371
+
372
+ // ๊ฒฐ๊ณผ ๋ Œ๋”๋ง (์ˆœ์„œ๋Œ€๋กœ)
373
+ content.innerHTML = bibleHtml + summaryHtml + vectorHtml;
374
+
375
+ } catch (error) {
376
+ console.error(error);
377
+ alert('์ž‘์—… ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message);
378
+ }
379
+ }
380
+ </script>
381
+ </body>
382
+ </html>
templates/novel_dashboard.html CHANGED
@@ -156,15 +156,18 @@ async function loadProjects() {
156
  </div>
157
  <p class="card-text text-muted small mb-0">ID: ${p.project_id.substring(0, 8)}...</p>
158
  </div>
159
- <div class="card-footer bg-white border-top-0 p-4 pt-0 d-flex gap-2">
160
- <a href="/creation/workspace/${p.project_id}" class="btn btn-outline-primary fw-medium flex-fill">
161
  <i class="fas fa-pen-nib me-1"></i> ์ง‘ํ•„ ์‹œ์ž‘
162
  </a>
163
- <a href="/creation/analysis/${p.project_id}" class="btn btn-outline-info fw-medium flex-fill">
164
- <i class="fas fa-chart-line me-1"></i> ๋ถ„์„ ํ™•์ธ
 
 
 
165
  </a>
166
  <button class="btn btn-outline-danger px-3" onclick="deleteProject('${p.project_id}', '${p.title.replace(/'/g, "\\'")}')" title="ํ”„๋กœ์ ํŠธ ์‚ญ์ œ">
167
- <i class="fas fa-trash-alt"></i> ์‚ญ์ œ
168
  </button>
169
  </div>
170
  </div>
 
156
  </div>
157
  <p class="card-text text-muted small mb-0">ID: ${p.project_id.substring(0, 8)}...</p>
158
  </div>
159
+ <div class="card-footer bg-white border-top-0 p-4 pt-0 d-flex flex-wrap gap-2">
160
+ <a href="/creation/workspace/${p.project_id}" class="btn btn-primary fw-medium flex-fill">
161
  <i class="fas fa-pen-nib me-1"></i> ์ง‘ํ•„ ์‹œ์ž‘
162
  </a>
163
+ <a href="/creation/trace/${p.project_id}" class="btn btn-outline-warning fw-medium flex-fill" title="์ƒ์„ฑ ๊ณผ์ • ํ™•์ธ">
164
+ <i class="fas fa-history me-1"></i> ๊ณผ์ • ์ถ”์ 
165
+ </a>
166
+ <a href="/creation/analysis/${p.project_id}" class="btn btn-outline-info fw-medium flex-fill" title="RAG/๋ฉ”๋ชจ๋ฆฌ ๋ถ„์„">
167
+ <i class="fas fa-chart-line me-1"></i> ๋ถ„์„
168
  </a>
169
  <button class="btn btn-outline-danger px-3" onclick="deleteProject('${p.project_id}', '${p.title.replace(/'/g, "\\'")}')" title="ํ”„๋กœ์ ํŠธ ์‚ญ์ œ">
170
+ <i class="fas fa-trash-alt"></i>
171
  </button>
172
  </div>
173
  </div>
templates/novel_story_view.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์†Œ์„ค ๋ณธ๋ฌธ ๋ณด๊ธฐ - {{ project.title }}{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <style>
7
+ .story-container {
8
+ max-width: 800px;
9
+ margin: 40px auto;
10
+ background: white;
11
+ padding: 60px;
12
+ box-shadow: 0 4px 20px rgba(0,0,0,0.08);
13
+ border-radius: 8px;
14
+ min-height: 100vh;
15
+ }
16
+ .story-title {
17
+ text-align: center;
18
+ margin-bottom: 50px;
19
+ font-size: 32px;
20
+ font-weight: 700;
21
+ color: #1a1a1a;
22
+ }
23
+ .story-content {
24
+ font-size: 18px;
25
+ line-height: 1.8;
26
+ color: #333;
27
+ white-space: pre-wrap;
28
+ }
29
+ .story-meta {
30
+ text-align: center;
31
+ color: #888;
32
+ margin-bottom: 20px;
33
+ font-size: 14px;
34
+ }
35
+ .back-btn {
36
+ position: fixed;
37
+ top: 20px;
38
+ left: 20px;
39
+ z-index: 100;
40
+ }
41
+ @media (max-width: 768px) {
42
+ .story-container {
43
+ padding: 30px 20px;
44
+ margin: 0;
45
+ border-radius: 0;
46
+ }
47
+ }
48
+ </style>
49
+ {% endblock %}
50
+
51
+ {% block content %}
52
+ <div class="back-btn">
53
+ {% if is_admin %}
54
+ <a href="/admin/stories" class="btn btn-outline-secondary">
55
+ <i class="fas fa-arrow-left me-2"></i> ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ
56
+ </a>
57
+ {% else %}
58
+ <a href="/creation/workspace/{{ project.project_id }}" class="btn btn-outline-secondary">
59
+ <i class="fas fa-arrow-left me-2"></i> ์ž‘์—…๊ณต๊ฐ„์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ
60
+ </a>
61
+ {% endif %}
62
+ </div>
63
+
64
+ <div class="story-container">
65
+ <div class="story-meta">SOY NV AI - Web Novel Export</div>
66
+ <h1 class="story-title">{{ project.title }}</h1>
67
+
68
+ <div class="story-content">
69
+ {% for msg in messages %}
70
+ {{ msg.content }}
71
+ <hr style="margin: 40px 0; border: none; border-top: 1px dashed #eee;">
72
+ {% endfor %}
73
+ </div>
74
+ </div>
75
+ {% endblock %}
76
+
templates/novel_trace_view.html ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์ƒ์„ฑ ๊ณผ์ • ํ™•์ธ - {{ project.title }}{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <style>
7
+ .trace-container {
8
+ max-width: 1000px;
9
+ margin: 40px auto;
10
+ padding: 20px;
11
+ }
12
+ .message-card {
13
+ background: white;
14
+ border-radius: 12px;
15
+ box-shadow: 0 2px 12px rgba(0,0,0,0.05);
16
+ margin-bottom: 24px;
17
+ overflow: hidden;
18
+ border: 1px solid #eee;
19
+ }
20
+ .message-header {
21
+ padding: 12px 20px;
22
+ background: #f8f9fa;
23
+ border-bottom: 1px solid #eee;
24
+ display: flex;
25
+ justify-content: space-between;
26
+ align-items: center;
27
+ }
28
+ .role-badge {
29
+ font-size: 12px;
30
+ font-weight: 600;
31
+ padding: 4px 10px;
32
+ border-radius: 20px;
33
+ text-transform: uppercase;
34
+ }
35
+ .role-user { background: #e0f2fe; color: #0369a1; }
36
+ .role-ai { background: #f0fdf4; color: #15803d; }
37
+ .role-ai-system { background: #fff7ed; color: #9a3412; border: 1px dashed #fdba74; }
38
+
39
+ .message-card.system-message {
40
+ opacity: 0.85;
41
+ border-left: 4px solid #f97316;
42
+ }
43
+
44
+ .message-body {
45
+ padding: 20px;
46
+ }
47
+ .content-section {
48
+ margin-bottom: 20px;
49
+ }
50
+ .content-label {
51
+ font-size: 11px;
52
+ font-weight: 700;
53
+ color: #94a3b8;
54
+ text-transform: uppercase;
55
+ letter-spacing: 0.05em;
56
+ margin-bottom: 8px;
57
+ display: block;
58
+ }
59
+ .content-text {
60
+ font-size: 15px;
61
+ line-height: 1.6;
62
+ color: #334155;
63
+ white-space: pre-wrap;
64
+ }
65
+ .meta-grid {
66
+ display: grid;
67
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
68
+ gap: 16px;
69
+ padding: 15px 20px;
70
+ background: #fafafa;
71
+ border-top: 1px solid #f0f0f0;
72
+ }
73
+ .meta-item {
74
+ font-size: 13px;
75
+ }
76
+ .meta-label { color: #64748b; font-weight: 500; }
77
+ .meta-value { color: #1e293b; font-weight: 600; }
78
+
79
+ .back-btn {
80
+ margin-bottom: 30px;
81
+ }
82
+ </style>
83
+ {% endblock %}
84
+
85
+ {% block content %}
86
+ <div class="trace-container">
87
+ <div class="back-btn">
88
+ {% if is_admin %}
89
+ <a href="/admin/traces" class="btn btn-outline-secondary">
90
+ <i class="fas fa-arrow-left me-2"></i> ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ
91
+ </a>
92
+ {% else %}
93
+ <a href="/creation/workspace/{{ project.project_id }}" class="btn btn-outline-secondary">
94
+ <i class="fas fa-arrow-left me-2"></i> ์ž‘์—…๊ณต๊ฐ„์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ
95
+ </a>
96
+ {% endif %}
97
+ </div>
98
+
99
+ <h2 class="mb-4">์ƒ์„ฑ ๊ณผ์ • ์ถ”์ : {{ project.title }}</h2>
100
+
101
+ {% if not messages %}
102
+ <div class="alert alert-info">์•„์ง ์ƒ์„ฑ๋œ ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>
103
+ {% endif %}
104
+
105
+ {% for msg in messages %}
106
+ <div class="message-card {% if msg.usage_type == 'system' %}system-message{% endif %}">
107
+ <div class="message-header">
108
+ <div>
109
+ <span class="role-badge role-{{ msg.role }}{% if msg.usage_type == 'system' %}-system{% endif %}">
110
+ {{ msg.role }}{% if msg.usage_type == 'system' %} (๋ฌธ์ฒด ์ ์šฉ ์ „ ์ดˆ์•ˆ){% endif %}
111
+ </span>
112
+ {% if msg.usage_type == 'system' %}
113
+ <span class="ms-2 badge bg-warning text-dark" style="font-size: 10px;">INTERNAL DRAFT</span>
114
+ {% endif %}
115
+ </div>
116
+ <span class="text-muted small">{{ msg.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
117
+ </div>
118
+ <div class="message-body">
119
+ <div class="content-section">
120
+ <span class="content-label">{% if msg.usage_type == 'system' %}์ดˆ์•ˆ ๋‚ด์šฉ (Before Style Refinement){% else %}๋ณธ๋ฌธ ๋‚ด์šฉ{% endif %}</span>
121
+ <div class="content-text">{{ msg.content }}</div>
122
+ </div>
123
+ </div>
124
+ {% if msg.role == 'ai' %}
125
+ <div class="meta-grid">
126
+ <div class="meta-item">
127
+ <span class="meta-label">๋ชจ๋ธ:</span>
128
+ <span class="meta-value">{{ msg.model_name or 'N/A' }}</span>
129
+ </div>
130
+ <div class="meta-item">
131
+ <span class="meta-label">์ž…๋ ฅ ํ† ํฐ:</span>
132
+ <span class="meta-value">{{ msg.input_tokens if msg.input_tokens is not none else '-' }}</span>
133
+ </div>
134
+ <div class="meta-item">
135
+ <span class="meta-label">์ถœ๋ ฅ ํ† ํฐ:</span>
136
+ <span class="meta-value">{{ msg.output_tokens if msg.output_tokens is not none else '-' }}</span>
137
+ </div>
138
+ </div>
139
+ {% endif %}
140
+ </div>
141
+ {% endfor %}
142
+ </div>
143
+ {% endblock %}
144
+
templates/novel_workspace.html CHANGED
@@ -257,26 +257,66 @@
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 {
@@ -401,39 +441,42 @@
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 {
@@ -441,20 +484,61 @@
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 {
@@ -549,12 +633,24 @@
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;">
@@ -564,6 +660,15 @@
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>
@@ -618,6 +723,14 @@
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>
@@ -659,6 +772,7 @@
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
 
@@ -689,6 +803,9 @@
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
  }
@@ -697,6 +814,10 @@
697
  localStorage.setItem('lastWriterModel', e.target.value);
698
  });
699
 
 
 
 
 
700
  // --- Markdown Rendering ---
701
  function renderMarkdown(text) {
702
  if (!text) return '';
@@ -743,10 +864,20 @@
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
 
@@ -836,18 +967,83 @@
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 ---
@@ -873,6 +1069,29 @@
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';
@@ -926,7 +1145,11 @@
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
 
@@ -938,7 +1161,8 @@
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๊ฐ€ ๋‚˜์ค‘์— ์—…๋ฐ์ดํŠธ๋˜๋Š” ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•˜์—ฌ ์—…๋ฐ์ดํŠธ
@@ -1152,6 +1376,18 @@
1152
  loadModels();
1153
  loadHistory();
1154
  loadViewerSettings();
 
 
 
 
 
 
 
 
 
 
 
 
1155
  });
1156
  </script>
1157
  {% endblock %}
 
257
  }
258
 
259
  .message-thoughts {
260
+ margin-top: 12px;
261
+ padding: 12px 16px;
262
+ background-color: rgba(30, 41, 59, 0.2);
263
+ border: 1px solid rgba(51, 65, 85, 0.3);
264
  border-radius: 12px;
265
  font-size: 13px;
266
  color: var(--text-muted);
 
267
  }
268
 
269
  .message-thoughts-header {
270
  display: flex;
271
  align-items: center;
272
+ justify-content: space-between;
273
+ margin-bottom: 8px;
274
+ font-size: 11px;
275
  font-weight: 700;
276
  text-transform: uppercase;
277
+ letter-spacing: 0.05em;
278
+ color: var(--accent);
279
+ cursor: pointer;
280
+ user-select: none;
281
+ }
282
+
283
+ .thought-steps {
284
+ display: flex;
285
+ flex-direction: column;
286
+ gap: 6px;
287
+ margin-top: 8px;
288
+ padding-top: 8px;
289
+ border-top: 1px solid rgba(51, 65, 85, 0.2);
290
+ }
291
+
292
+ .thought-step {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 8px;
296
+ font-size: 11px;
297
+ color: rgba(148, 163, 184, 0.8);
298
+ }
299
+
300
+ .thought-step i {
301
+ font-size: 10px;
302
+ color: #22c55e;
303
+ }
304
+
305
+ /* Progress Indicator CSS */
306
+ .progress-container {
307
+ width: 100%;
308
+ background-color: rgba(30, 41, 59, 0.5);
309
+ border-radius: 4px;
310
+ height: 4px;
311
+ margin: 8px 0;
312
+ overflow: hidden;
313
+ }
314
+
315
+ .progress-bar {
316
+ height: 100%;
317
+ background: linear-gradient(90deg, var(--accent), #818cf8);
318
+ width: 0%;
319
+ transition: width 0.5s ease;
320
  }
321
 
322
  .message-image {
 
441
  background-color: rgba(30, 41, 59, 0.4);
442
  border: 1px solid rgba(51, 65, 85, 0.5);
443
  border-radius: 16px;
444
+ padding: 20px;
445
+ box-shadow: inset 0 1px 1px rgba(255,255,255,0.05);
446
  }
447
 
448
  .user-profile {
449
  display: flex;
450
  align-items: center;
451
+ gap: 16px;
452
+ margin-bottom: 20px;
453
  }
454
 
455
  .user-avatar {
456
+ width: 48px;
457
+ height: 48px;
458
+ background-color: rgba(59, 130, 246, 0.1);
459
+ border-radius: 12px;
460
  display: flex;
461
  align-items: center;
462
  justify-content: center;
463
+ color: var(--accent);
464
+ border: 1px solid rgba(59, 130, 246, 0.2);
465
+ font-size: 18px;
466
  }
467
 
468
  .user-info-name {
469
+ font-size: 15px;
470
  font-weight: 700;
471
  color: var(--text-main);
472
+ letter-spacing: -0.02em;
473
  }
474
 
475
  .user-info-role {
476
+ font-size: 11px;
477
  color: var(--text-muted);
478
+ font-weight: 500;
479
+ margin-top: 2px;
480
  }
481
 
482
  .status-items {
 
484
  padding-top: 12px;
485
  display: flex;
486
  flex-direction: column;
487
+ gap: 2px;
488
  }
489
 
490
  .status-item {
491
  display: flex;
492
  justify-content: space-between;
493
+ align-items: flex-start;
494
  font-size: 11px;
495
+ gap: 12px;
496
+ padding: 8px 0;
497
+ border-bottom: 1px solid rgba(51, 65, 85, 0.1);
498
+ }
499
+
500
+ .status-item:last-child {
501
+ border-bottom: none;
502
  }
503
 
504
  .status-item-label {
505
  color: var(--text-muted);
506
  display: flex;
507
  align-items: center;
508
+ gap: 10px;
509
+ white-space: nowrap;
510
+ flex-shrink: 0;
511
+ padding-top: 2px;
512
+ }
513
+
514
+ .status-item-label i {
515
+ width: 14px;
516
+ text-align: center;
517
+ color: var(--accent);
518
+ opacity: 0.7;
519
+ }
520
+
521
+ .status-item-value {
522
+ text-align: right;
523
+ color: var(--text-main);
524
+ font-weight: 500;
525
+ line-height: 1.5;
526
+ word-break: keep-all;
527
+ }
528
+
529
+ /* Stats ์ „์šฉ ์Šคํƒ€์ผ - ๋‚ด์šฉ์ด ๊ธธ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ค„๋ฐ”๊ฟˆ ํ—ˆ์šฉ */
530
+ #status-stats-row {
531
+ flex-direction: column;
532
+ align-items: stretch;
533
+ gap: 6px;
534
+ }
535
+
536
+ #status-stats-row .status-item-value {
537
+ text-align: left;
538
+ padding-left: 24px;
539
+ font-size: 10.5px;
540
+ color: rgba(241, 245, 249, 0.8);
541
+ line-height: 1.6;
542
  }
543
 
544
  .objective-card {
 
633
  <i class="fas fa-robot"></i>
634
  </div>
635
  <div>
636
+ <h1 class="chat-header-title">{{ project.title }} ({{ public_styles|length }})</h1>
637
  <p class="chat-header-subtitle">{{ project.mode }} MODE</p>
638
  </div>
639
  </div>
640
 
641
  <div style="display: flex; align-items: center; gap: 16px;">
642
+ <div class="dropdown">
643
+ <button class="btn btn-outline-light btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 11px; background: rgba(255,255,255,0.05); border: 1px solid var(--border-color); color: var(--text-muted);">
644
+ <i class="fas fa-eye me-1"></i> ๋ณด๊ธฐ ๋ชจ๋“œ
645
+ </button>
646
+ <ul class="dropdown-menu dropdown-menu-dark shadow" style="background-color: var(--bg-message-ai); border: 1px solid var(--border-color); font-size: 13px;">
647
+ <li><a class="dropdown-item py-2" href="/creation/story/{{ project.project_id }}"><i class="fas fa-book me-2 text-success"></i> ์†Œ์„ค ๋ณธ๋ฌธ๋งŒ ๋ณด๊ธฐ</a></li>
648
+ <li><a class="dropdown-item py-2" href="/creation/trace/{{ project.project_id }}"><i class="fas fa-history me-2 text-warning"></i> ์ƒ์„ฑ ๊ณผ์ • ์ถ”์ ํ•˜๊ธฐ</a></li>
649
+ <li><hr class="dropdown-divider" style="border-color: var(--border-color);"></li>
650
+ <li><a class="dropdown-item py-2" href="/creation/analysis/{{ project.project_id }}"><i class="fas fa-chart-line me-2 text-info"></i> ๋ถ„์„ ๋ฆฌํฌํŠธ ํ™•์ธ</a></li>
651
+ </ul>
652
+ </div>
653
+
654
  <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;">
655
  <label style="display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-muted); cursor: pointer;">
656
  <input type="checkbox" id="viewer-toggle" style="cursor: pointer;">
 
660
  <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;">
661
  <i class="fas fa-external-link-alt"></i> ๋ทฐ์–ด ์—ด๊ธฐ
662
  </a>
663
+ <select id="style-selector" class="model-select">
664
+ <option value="">๋ฌธ์ฒด ์„ ํƒ (๊ธฐ๋ณธ)</option>
665
+ {% if not public_styles %}
666
+ <option value="" disabled>๊ณต๊ฐœ๋œ ๋ฌธ์ฒด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค</option>
667
+ {% endif %}
668
+ {% for style in public_styles %}
669
+ <option value="{{ style.id }}">{{ style.file.original_filename if style.file else '๋ฌธ์ฒด #' ~ style.id }}</option>
670
+ {% endfor %}
671
+ </select>
672
  <select id="model-selector" class="model-select">
673
  <!-- Models will be loaded here -->
674
  </select>
 
723
  <span class="status-item-label"><i class="fas fa-clock"></i> Time</span>
724
  <span id="status-time" class="status-item-value">D+0000 | 00:00</span>
725
  </div>
726
+ <div class="status-item" id="status-hp-row" style="display: none;">
727
+ <span class="status-item-label"><i class="fas fa-heart"></i> HP</span>
728
+ <span id="status-hp" class="status-item-value">-</span>
729
+ </div>
730
+ <div class="status-item" id="status-stats-row" style="display: none;">
731
+ <span class="status-item-label"><i class="fas fa-chart-line"></i> Stats</span>
732
+ <span id="status-stats" class="status-item-value">-</span>
733
+ </div>
734
  </div>
735
  </div>
736
  </div>
 
772
  const chatMessages = document.getElementById('chat-messages');
773
  const chatMessagesInner = document.getElementById('chat-messages-inner');
774
  const modelSelector = document.getElementById('model-selector');
775
+ const styleSelector = document.getElementById('style-selector');
776
  const toggleSidebarBtn = document.getElementById('toggle-sidebar');
777
  const sidebar = document.getElementById('sidebar');
778
 
 
803
  const defaultModel = data.models.find(m => m.name.includes('gemini')) || data.models[0];
804
  modelSelector.value = defaultModel.name;
805
  }
806
+
807
+ const savedStyle = localStorage.getItem('lastWriterStyle');
808
+ if (savedStyle) styleSelector.value = savedStyle;
809
  }
810
  } catch (e) { console.error('Failed to load models:', e); }
811
  }
 
814
  localStorage.setItem('lastWriterModel', e.target.value);
815
  });
816
 
817
+ styleSelector.addEventListener('change', (e) => {
818
+ localStorage.setItem('lastWriterStyle', e.target.value);
819
+ });
820
+
821
  // --- Markdown Rendering ---
822
  function renderMarkdown(text) {
823
  if (!text) return '';
 
864
 
865
  if (role === 'ai' && extra.thoughts) {
866
  const renderedThoughts = renderMarkdown(extra.thoughts);
867
+ const hasStyle = extra.styleApplied ? '<div class="thought-step"><i class="fas fa-magic"></i> <span>Style Bible ๋ฌธ์ฒด ๋ณ€ํ™˜ ์™„๋ฃŒ</span></div>' : '';
868
  html += `
869
  <div class="message-thoughts">
870
+ <div class="message-thoughts-header">
871
+ <span><i class="fas fa-brain"></i> AI ์ถ”๋ก  ๊ณผ์ •</span>
872
+ <i class="fas fa-chevron-down"></i>
873
+ </div>
874
  <div class="markdown-content">${renderedThoughts}</div>
875
+ <div class="thought-steps">
876
+ <div class="thought-step"><i class="fas fa-check"></i> <span>์„ธ๊ณ„๊ด€ ๋ฐ ๋งฅ๋ฝ ๋ถ„์„</span></div>
877
+ <div class="thought-step"><i class="fas fa-check"></i> <span>์žฅ๋ฉด ์ „๊ฐœ ๊ตฌ์ƒ</span></div>
878
+ ${hasStyle}
879
+ <div class="thought-step"><i class="fas fa-check"></i> <span>์ตœ์ข… ํ…์ŠคํŠธ ์ƒ์„ฑ</span></div>
880
+ </div>
881
  </div>`;
882
  }
883
 
 
967
  indicator.id = 'typing-indicator';
968
  indicator.className = 'message message-ai';
969
  indicator.innerHTML = `
970
+ <div class="message-bubble" style="min-width: 280px; background-color: rgba(30, 41, 59, 0.4); border: 1px solid var(--border-color);">
971
+ <div style="display: flex; flex-direction: column; gap: 12px; padding: 4px;">
972
+ <div style="display: flex; align-items: center; justify-content: space-between;">
973
+ <div style="display: flex; align-items: center; gap: 8px;">
974
+ <i class="fas fa-robot fa-spin" style="color: var(--accent); font-size: 14px;"></i>
975
+ <span id="typing-status-text" style="font-size: 13px; font-weight: 600; color: var(--text-main);">์—์ด์ „ํŠธ ๊ฐ€๋™ ์ค‘...</span>
976
+ </div>
977
+ <span id="typing-percent" style="font-size: 11px; color: var(--accent); font-weight: 700;">0%</span>
978
+ </div>
979
+
980
+ <div class="progress-container">
981
+ <div id="typing-progress-bar" class="progress-bar"></div>
982
+ </div>
983
+
984
+ <div id="typing-log" style="display: flex; flex-direction: column; gap: 6px;">
985
+ <div class="thought-step" style="opacity: 1;"><i class="fas fa-check"></i> <span>์‚ฌ์šฉ์ž ์š”์ฒญ ์ˆ˜์‹ </span></div>
986
+ </div>
987
  </div>
988
  </div>`;
989
  chatMessagesInner.appendChild(indicator);
990
  scrollToBottom();
991
+
992
+ // ์‹œ๋ฎฌ๋ ˆ์ด์…˜๋œ ์ง„ํ–‰ ๋กœ๊ทธ ์—…๋ฐ์ดํŠธ
993
+ const steps = [
994
+ { text: "์„ธ๊ณ„๊ด€ ๋ฐ ์บ๋ฆญํ„ฐ ์„ค์ • ๋ถ„์„ ์ค‘...", p: 15 },
995
+ { text: "์„œ์‚ฌ์  ์ „๊ฐœ ๋งฅ๋ฝ(Zep) ๋กœ๋“œ ์™„๋ฃŒ", p: 30 },
996
+ { text: "๋‹ค์Œ ์žฅ๋ฉด ์ „๊ฐœ ๊ตฌ์ƒ ์ค‘...", p: 45 },
997
+ { text: "์ดˆ์•ˆ ์ž‘์„ฑ์„ ์œ„ํ•œ AI ์ถ”๋ก  ์‹œ์ž‘", p: 60 },
998
+ { text: "Style Bible ๋ฌธ์ฒด ์ ์šฉ ๋ฐ ๋ณ€ํ™˜ ์ค‘...", p: 80 },
999
+ { text: "์ตœ์ข… ๊ฒฐ๊ณผ๋ฌผ ๊ฒ€ํ†  ๋ฐ ๋‹ค๋“ฌ๊ธฐ", p: 95 }
1000
+ ];
1001
+
1002
+ let currentStep = 0;
1003
+ const logInterval = setInterval(() => {
1004
+ const statusText = document.getElementById('typing-status-text');
1005
+ const progressBar = document.getElementById('typing-progress-bar');
1006
+ const percentText = document.getElementById('typing-percent');
1007
+ const logContainer = document.getElementById('typing-log');
1008
+
1009
+ if (!statusText || currentStep >= steps.length) {
1010
+ clearInterval(logInterval);
1011
+ return;
1012
+ }
1013
+
1014
+ const step = steps[currentStep];
1015
+ statusText.textContent = step.text;
1016
+ progressBar.style.width = `${step.p}%`;
1017
+ percentText.textContent = `${step.p}%`;
1018
+
1019
+ const logEntry = document.createElement('div');
1020
+ logEntry.className = 'thought-step';
1021
+ logEntry.innerHTML = `<i class="fas fa-check"></i> <span>${step.text}</span>`;
1022
+ logContainer.appendChild(logEntry);
1023
+
1024
+ // ๋กœ๊ทธ๊ฐ€ ๋งŽ์•„์ง€๋ฉด ์˜ค๋ž˜๋œ ๊ฒƒ ํˆฌ๋ช…๋„ ์กฐ์ ˆ
1025
+ const children = logContainer.children;
1026
+ if (children.length > 3) {
1027
+ for(let i = 0; i < children.length - 3; i++) {
1028
+ children[i].style.opacity = "0.4";
1029
+ }
1030
+ }
1031
+
1032
+ currentStep++;
1033
+ scrollToBottom();
1034
+ }, 2500);
1035
+
1036
+ indicator.dataset.intervalId = logInterval;
1037
  }
1038
 
1039
  function hideTypingIndicator() {
1040
  const indicator = document.getElementById('typing-indicator');
1041
+ if (indicator) {
1042
+ if (indicator.dataset.intervalId) {
1043
+ clearInterval(indicator.dataset.intervalId);
1044
+ }
1045
+ indicator.remove();
1046
+ }
1047
  }
1048
 
1049
  // --- Chat History ---
 
1069
  document.getElementById('status-location').textContent = status.location || 'Unknown';
1070
  document.getElementById('status-time').textContent = status.current_time || 'D+0000 | 00:00';
1071
 
1072
+ // ์บ๋ฆญํ„ฐ ์ด๋ฆ„ ๋ฐ ์†Œ์† ์—…๋ฐ์ดํŠธ
1073
+ if (status.name) {
1074
+ document.querySelector('.user-info-name').textContent = status.name;
1075
+ }
1076
+ if (status.affiliation) {
1077
+ document.querySelector('.user-info-role').textContent = status.affiliation;
1078
+ }
1079
+
1080
+ // HP ๋ฐ ๋Šฅ๋ ฅ์น˜ ์—…๋ฐ์ดํŠธ
1081
+ if (status.hp_status) {
1082
+ document.getElementById('status-hp-row').style.display = 'flex';
1083
+ document.getElementById('status-hp').textContent = status.hp_status;
1084
+ } else {
1085
+ document.getElementById('status-hp-row').style.display = 'none';
1086
+ }
1087
+
1088
+ if (status.stats) {
1089
+ document.getElementById('status-stats-row').style.display = 'flex';
1090
+ document.getElementById('status-stats').textContent = status.stats;
1091
+ } else {
1092
+ document.getElementById('status-stats-row').style.display = 'none';
1093
+ }
1094
+
1095
  const objectiveSection = document.getElementById('objective-section');
1096
  if (status.quest_status) {
1097
  objectiveSection.style.display = 'block';
 
1145
  const response = await fetch(`/creation/api/chat/${projectId}`, {
1146
  method: 'POST',
1147
  headers: { 'Content-Type': 'application/json' },
1148
+ body: JSON.stringify({
1149
+ message,
1150
+ model: modelSelector.value,
1151
+ style_id: styleSelector.value
1152
+ })
1153
  });
1154
  const data = await response.json();
1155
 
 
1161
  const aiMessageId = data.message_id || null;
1162
  const messageEl = addMessage('ai', data.reply, {
1163
  thoughts: data.hidden_thoughts,
1164
+ imageUrl: data.image_url,
1165
+ styleApplied: data.style_applied
1166
  }, aiMessageId);
1167
 
1168
  // messageId๊ฐ€ ๋‚˜์ค‘์— ์—…๋ฐ์ดํŠธ๋˜๋Š” ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•˜์—ฌ ์—…๋ฐ์ดํŠธ
 
1376
  loadModels();
1377
  loadHistory();
1378
  loadViewerSettings();
1379
+
1380
+ // ๊ธฐ์กด ์ƒํƒœ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
1381
+ {% if project.last_status %}
1382
+ try {
1383
+ const initialStatus = JSON.parse('{{ project.last_status | safe }}');
1384
+ if (initialStatus) {
1385
+ updateStatus(initialStatus);
1386
+ }
1387
+ } catch (e) {
1388
+ console.error('Failed to parse initial status:', e);
1389
+ }
1390
+ {% endif %}
1391
  });
1392
  </script>
1393
  {% endblock %}