SOY NV AI commited on
Commit
42090e1
·
1 Parent(s): 6a0cd71

feat: ?뱀냼??李쎌옉 ?€?쒕낫??諛??묒뾽怨듦컙 UI 援ы쁽

Browse files
app/__init__.py CHANGED
@@ -263,6 +263,9 @@ def create_app() -> Flask:
263
  from app.routes import main_bp
264
  app.register_blueprint(main_bp)
265
 
 
 
 
266
  @app.context_processor
267
  def inject_i18n():
268
  lang = session.get('lang', 'vi')
 
263
  from app.routes import main_bp
264
  app.register_blueprint(main_bp)
265
 
266
+ from app.routers.creation import creation_bp
267
+ app.register_blueprint(creation_bp)
268
+
269
  @app.context_processor
270
  def inject_i18n():
271
  lang = session.get('lang', 'vi')
app/agent/agent.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from typing import Optional, Union
4
+ from pydantic_ai import Agent, RunContext
5
+ from pydantic_ai.models import Model
6
+ from app.agent.deps import NovelWriterDeps
7
+ from app.models.project_config import ProjectMode
8
+
9
+ def get_model() -> Union[str, Model]:
10
+ """환경 변수에 따라 적절한 모델을 반환합니다."""
11
+ if os.environ.get('OPENAI_API_KEY'):
12
+ return 'openai:gpt-4o'
13
+ elif os.environ.get('GEMINI_API_KEY'):
14
+ return 'google-gla:gemini-1.5-flash'
15
+ else:
16
+ # API 키가 없을 경우 테스트 모델을 반환하여 서버 기동을 허용합니다.
17
+ from pydantic_ai.models.test import TestModel
18
+ return TestModel()
19
+
20
+ # PydanticAI 에이전트 정의
21
+ novel_agent = Agent(
22
+ get_model(),
23
+ deps_type=NovelWriterDeps,
24
+ system_prompt="당신은 전문 웹소설 작가 어시스턴트입니다."
25
+ )
26
+
27
+ @novel_agent.system_prompt
28
+ async def dynamic_system_prompt(ctx: RunContext[NovelWriterDeps]) -> str:
29
+ """Fetch facts and context in parallel to build a dynamic system prompt."""
30
+ project = ctx.deps.current_project
31
+
32
+ # Zep(Context)과 Mem0(Facts) 데이터를 병렬로 조회
33
+ context_task = ctx.deps.zep_client.get_session_context(project.project_id, "default_session")
34
+ facts_task = ctx.deps.mem0_client.get_facts(project.project_id)
35
+
36
+ context, facts = await asyncio.gather(context_task, facts_task)
37
+
38
+ facts_str = "\n- ".join(facts)
39
+
40
+ prompt = f"""
41
+ [현재 프로젝트: {project.title}]
42
+ 모드: {project.mode.value}
43
+
44
+ [기존 설정 및 사실관계 (Mem0)]
45
+ - {facts_str}
46
+
47
+ [스토리 맥락 및 요약 (Zep)]
48
+ {context}
49
+
50
+ 위 정보를 바탕으로 소설 작성을 도와주세요.
51
+ 일관성 있는 캐릭터와 전개를 유지하는 것이 가장 중요합니다.
52
+ """
53
+ return prompt
54
+
55
+ @novel_agent.tool
56
+ async def consult_knowledge_base(ctx: RunContext[NovelWriterDeps], query: str) -> str:
57
+ """기존 자료나 참고 설정을 검색합니다. (REFERENCE 모드에서만 사용 가능)"""
58
+ project = ctx.deps.current_project
59
+
60
+ if project.mode != ProjectMode.REFERENCE:
61
+ return "현재 '순수 창작(CREATIVE)' 모드이므로 외부 지식 베이스를 사용할 수 없습니다."
62
+
63
+ if not project.reference_source_id:
64
+ return "참고 자료 ID가 설정되지 않았습니다."
65
+
66
+ return await ctx.deps.rag_service.query_knowledge(query, project.reference_source_id)
67
+
app/agent/deps.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+ from app.models.project_config import ProjectConfig
4
+
5
+ @dataclass
6
+ class NovelWriterDeps:
7
+ """Dependency injection for the Novel AI Agent."""
8
+ user_id: str
9
+ current_project: ProjectConfig
10
+ mem0_client: Any # Mem0 client instance
11
+ zep_client: Any # Zep client instance
12
+ rag_service: Any # Interface for existing VectorDB/GraphRAG
13
+
app/models/project_config.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+ class ProjectMode(str, Enum):
6
+ CREATIVE = "CREATIVE" # 순수 창작 모드
7
+ REFERENCE = "REFERENCE" # 기존 작품 참고 모드
8
+
9
+ class ProjectConfig(BaseModel):
10
+ project_id: str = Field(..., description="Unique ID for the novel project")
11
+ title: str = Field(..., description="Title of the novel")
12
+ mode: ProjectMode = Field(default=ProjectMode.CREATIVE)
13
+ reference_source_id: Optional[str] = Field(None, description="Vector DB collection ID for REFERENCE mode")
14
+ user_id: str = Field(..., description="Owner of the project")
15
+
16
+ class Config:
17
+ from_attributes = True
18
+
app/routers/creation.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, request, jsonify, render_template, current_app
2
+ from typing import List
3
+ from app.models.project_config import ProjectConfig, ProjectMode
4
+ from app.agent.agent import novel_agent
5
+ from app.agent.deps import NovelWriterDeps
6
+ from app.services.memory_service import ZepService, Mem0Service
7
+ from app.services.rag_service import RAGService
8
+ import uuid
9
+ import asyncio
10
+
11
+ creation_bp = Blueprint('creation', __name__, url_prefix='/creation')
12
+
13
+ # 임시 인메모리 저장소 (실제 운영 시 DB 연동 필요)
14
+ projects_db = {}
15
+
16
+ def get_deps(project_id: str, user_id: str = "user_123") -> NovelWriterDeps:
17
+ if project_id not in projects_db:
18
+ return None
19
+
20
+ project = projects_db[project_id]
21
+
22
+ # 설정값은 실제 환경변수 등에서 가져와야 함
23
+ return NovelWriterDeps(
24
+ user_id=user_id,
25
+ current_project=project,
26
+ mem0_client=Mem0Service(api_key="mem0_key"),
27
+ zep_client=ZepService(api_key="zep_key", api_url="http://zep:8000"),
28
+ rag_service=RAGService()
29
+ )
30
+
31
+ @creation_bp.route('/webnovel', methods=['GET'])
32
+ def webnovel():
33
+ """웹소설 대시보드 페이지"""
34
+ return render_template('novel_dashboard.html', title="웹소설 창작")
35
+
36
+ @creation_bp.route('/workspace/<project_id>', methods=['GET'])
37
+ def workspace(project_id: str):
38
+ """웹소설 창작 작업공간 페이지"""
39
+ if project_id not in projects_db:
40
+ return "Project not found", 404
41
+ project = projects_db[project_id]
42
+ return render_template('novel_workspace.html', project=project)
43
+
44
+ @creation_bp.route('/api/projects', methods=['POST'])
45
+ def create_project():
46
+ """새로운 웹소설 프로젝트를 생성합니다."""
47
+ data = request.get_json()
48
+ title = data.get('title')
49
+ mode_str = data.get('mode', 'CREATIVE')
50
+ reference_id = data.get('reference_id')
51
+
52
+ project_id = str(uuid.uuid4())
53
+ project = ProjectConfig(
54
+ project_id=project_id,
55
+ title=title,
56
+ mode=ProjectMode(mode_str),
57
+ reference_source_id=reference_id,
58
+ user_id="user_123"
59
+ )
60
+ projects_db[project_id] = project
61
+ return jsonify(project.model_dump())
62
+
63
+ @creation_bp.route('/api/projects', methods=['GET'])
64
+ def list_projects():
65
+ """모든 프로젝트 목록을 가져옵니다."""
66
+ return jsonify([p.model_dump() for p in projects_db.values()])
67
+
68
+ @creation_bp.route('/api/chat/<project_id>', methods=['POST'])
69
+ async def chat_with_agent(project_id: str):
70
+ """에이전트와 채팅(집필 도움)을 진행합니다."""
71
+ data = request.get_json()
72
+ message = data.get('message')
73
+
74
+ deps = get_deps(project_id)
75
+ if not deps:
76
+ return jsonify({"error": "Project not found"}), 404
77
+
78
+ # PydanticAI 에이전트 실행 (async)
79
+ # Flask에서 async route를 지원하려면 flask[async]가 필요하거나 직접 루프를 돌려야 함
80
+ # 최신 Flask 3.0.0은 async def를 지원함
81
+ result = await novel_agent.run(message, deps=deps)
82
+ return jsonify({"reply": result.data})
app/routes.py CHANGED
@@ -44,10 +44,19 @@ def get_default_admin_menu():
44
  return {
45
  "version": 1,
46
  "sections": [
 
 
 
 
 
 
 
47
  {
48
  "label": "사이트 관리",
49
  "roles": ["admin"],
50
  "items": [
 
 
51
  {"label": "사용자 관리", "endpoint": "main.admin", "roles": ["admin"]},
52
  {"label": "토큰 통계", "endpoint": "main.admin_tokens", "roles": ["admin"]},
53
  {"label": "메뉴 관리", "endpoint": "main.admin_menu", "roles": ["admin"]},
 
44
  return {
45
  "version": 1,
46
  "sections": [
47
+ {
48
+ "label": "창작",
49
+ "roles": ["admin", "webtoon_pm", "webtoon_admin", "producer"],
50
+ "items": [
51
+ {"label": "웹소설", "endpoint": "creation.webnovel"},
52
+ ],
53
+ },
54
  {
55
  "label": "사이트 관리",
56
  "roles": ["admin"],
57
  "items": [
58
+ {"label": "설정", "endpoint": "main.admin_settings"},
59
+ {"label": "RAG 관리", "endpoint": "main.webnovels"},
60
  {"label": "사용자 관리", "endpoint": "main.admin", "roles": ["admin"]},
61
  {"label": "토큰 통계", "endpoint": "main.admin_tokens", "roles": ["admin"]},
62
  {"label": "메뉴 관리", "endpoint": "main.admin_menu", "roles": ["admin"]},
app/services/memory_service.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from typing import List, Dict, Any, Optional
3
+
4
+ class ZepService:
5
+ """Zep (Long-term Memory) integration."""
6
+ def __init__(self, api_key: str, api_url: str):
7
+ self.api_key = api_key
8
+ self.api_url = api_url
9
+
10
+ async def get_session_context(self, project_id: str, session_id: str) -> str:
11
+ """Fetch session history and summaries from Zep."""
12
+ zep_session_id = f"{project_id}_{session_id}"
13
+ # TODO: Implement actual Zep client call
14
+ await asyncio.sleep(0.1) # Simulate I/O
15
+ return f"Summary of project {project_id} from Zep..."
16
+
17
+ class Mem0Service:
18
+ """Mem0 (Entity/Fact Memory) integration."""
19
+ def __init__(self, api_key: str):
20
+ self.api_key = api_key
21
+
22
+ async def get_facts(self, project_id: str) -> List[str]:
23
+ """Fetch character and setting facts filtered by project_id."""
24
+ # TODO: Implement actual Mem0 client call with metadata filter
25
+ await asyncio.sleep(0.1) # Simulate I/O
26
+ return [f"Character info for project {project_id} from Mem0"]
27
+
28
+ async def add_fact(self, project_id: str, fact: str):
29
+ """Store a new fact into Mem0."""
30
+ # TODO: Implement storage
31
+ pass
32
+
app/services/rag_service.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any
2
+ from app.vector_db import VectorDBManager
3
+
4
+ class RAGService:
5
+ """Wrapper for the existing RAG (VectorDB + GraphRAG) system."""
6
+ def __init__(self):
7
+ self.vector_db = VectorDBManager()
8
+
9
+ async def query_knowledge(self, query: str, collection_id: str) -> str:
10
+ """Search across existing RAG knowledge base."""
11
+ # 기존 vector_db.search_with_rerank 또는 유사한 메서드 활용
12
+ # 여기서는 인터페이스 예시만 작성
13
+ results = self.vector_db.search_with_rerank(query, top_k=3)
14
+ context = "\n".join([r['content'] for r in results])
15
+ return context
16
+
requirements.txt CHANGED
@@ -21,3 +21,7 @@ psycopg2-binary # PostgreSQL support for external database
21
  ollama
22
  holidays
23
 
 
 
 
 
 
21
  ollama
22
  holidays
23
 
24
+ # AI Agent Frameworks
25
+ pydantic-ai
26
+ zep-python
27
+ mem0ai
templates/novel_dashboard.html ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin.html" %}
2
+
3
+ {% block content %}
4
+ <div class="container-fluid mt-4">
5
+ <div class="d-flex justify-content-between align-items-center mb-4">
6
+ <h2><i class="fas fa-book-open me-2"></i>웹소설 프로젝트 대시보드</h2>
7
+ <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createProjectModal">
8
+ <i class="fas fa-plus me-1"></i> 새 프로젝트 생성
9
+ </button>
10
+ </div>
11
+
12
+ <div class="row" id="projectList">
13
+ <!-- 프로젝트 카드가 여기에 동적으로 추가됩니다 -->
14
+ <div class="col-12 text-center py-5">
15
+ <div class="spinner-border text-primary" role="status">
16
+ <span class="visually-hidden">Loading...</span>
17
+ </div>
18
+ <p class="mt-2 text-muted">프로젝트를 불러오는 중...</p>
19
+ </div>
20
+ </div>
21
+ </div>
22
+
23
+ <!-- 프로젝트 생성 모달 -->
24
+ <div class="modal fade" id="createProjectModal" tabindex="-1" aria-hidden="true">
25
+ <div class="modal-dialog">
26
+ <div class="modal-content">
27
+ <div class="modal-header">
28
+ <h5 class="modal-title">새 웹소설 프로젝트</h5>
29
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
30
+ </div>
31
+ <div class="modal-body">
32
+ <form id="createProjectForm">
33
+ <div class="mb-3">
34
+ <label class="form-label">제목</label>
35
+ <input type="text" class="form-control" id="projectTitle" required placeholder="소설 제목을 입력하세요">
36
+ </div>
37
+ <div class="mb-3">
38
+ <label class="form-label">창작 모드</label>
39
+ <select class="form-select" id="projectMode">
40
+ <option value="CREATIVE">순수 창작 (Pure Imagination)</option>
41
+ <option value="REFERENCE">기존 작품 참고 (RAG Enabled)</option>
42
+ </select>
43
+ </div>
44
+ <div class="mb-3 d-none" id="referenceSection">
45
+ <label class="form-label">참고 자료 ID (RAG Collection)</label>
46
+ <input type="text" class="form-control" id="referenceId" placeholder="참고할 벡터 컬렉션 ID">
47
+ </div>
48
+ </form>
49
+ </div>
50
+ <div class="modal-footer">
51
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
52
+ <button type="button" class="btn btn-primary" onclick="createNewProject()">생성하기</button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <script>
59
+ document.getElementById('projectMode').addEventListener('change', function() {
60
+ const refSection = document.getElementById('referenceSection');
61
+ if (this.value === 'REFERENCE') {
62
+ refSection.classList.remove('d-none');
63
+ } else {
64
+ refSection.classList.add('d-none');
65
+ }
66
+ });
67
+
68
+ async function loadProjects() {
69
+ try {
70
+ const response = await fetch('/creation/api/projects');
71
+ const projects = await response.json();
72
+ const container = document.getElementById('projectList');
73
+
74
+ if (projects.length === 0) {
75
+ container.innerHTML = `
76
+ <div class="col-12 text-center py-5 text-muted">
77
+ <i class="fas fa-folder-open fa-3x mb-3"></i>
78
+ <p>생성된 프로젝트가 없습니다. 첫 프로젝트를 만들어보세요!</p>
79
+ </div>`;
80
+ return;
81
+ }
82
+
83
+ container.innerHTML = projects.map(p => `
84
+ <div class="col-md-4 mb-4">
85
+ <div class="card h-100 shadow-sm">
86
+ <div class="card-body">
87
+ <div class="d-flex justify-content-between align-items-start mb-2">
88
+ <h5 class="card-title text-truncate" style="max-width: 80%;">${p.title}</h5>
89
+ <span class="badge ${p.mode === 'CREATIVE' ? 'bg-success' : 'bg-info'}">${p.mode}</span>
90
+ </div>
91
+ <p class="card-text text-muted small">ID: ${p.project_id.substring(0, 8)}...</p>
92
+ </div>
93
+ <div class="card-footer bg-transparent border-top-0 d-grid">
94
+ <a href="/creation/workspace/${p.project_id}" class="btn btn-outline-primary">
95
+ <i class="fas fa-pen-nib me-1"></i> 집필 시작
96
+ </a>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ `).join('');
101
+ } catch (error) {
102
+ console.error('Failed to load projects:', error);
103
+ }
104
+ }
105
+
106
+ async function createNewProject() {
107
+ const title = document.getElementById('projectTitle').value;
108
+ const mode = document.getElementById('projectMode').value;
109
+ const reference_id = document.getElementById('referenceId').value;
110
+
111
+ if (!title) return alert('제목을 입력해주세요.');
112
+
113
+ try {
114
+ const response = await fetch('/creation/api/projects', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ title, mode, reference_id })
118
+ });
119
+
120
+ if (response.ok) {
121
+ bootstrap.Modal.getInstance(document.getElementById('createProjectModal')).hide();
122
+ loadProjects();
123
+ }
124
+ } catch (error) {
125
+ alert('프로젝트 생성 실패');
126
+ }
127
+ }
128
+
129
+ document.addEventListener('DOMContentLoaded', loadProjects);
130
+ </script>
131
+ {% endblock %}
132
+
templates/novel_workspace.html ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin.html" %}
2
+
3
+ {% block content %}
4
+ <style>
5
+ .workspace-container {
6
+ display: flex;
7
+ height: calc(100vh - 100px);
8
+ background: #f8f9fa;
9
+ overflow: hidden;
10
+ }
11
+ .editor-area {
12
+ flex: 1;
13
+ display: flex;
14
+ flex-direction: column;
15
+ padding: 20px;
16
+ overflow-y: auto;
17
+ }
18
+ .memory-sidebar {
19
+ width: 320px;
20
+ background: white;
21
+ border-left: 1px solid #dee2e6;
22
+ display: flex;
23
+ flex-direction: column;
24
+ padding: 15px;
25
+ overflow-y: auto;
26
+ }
27
+ .chat-bubble {
28
+ max-width: 80%;
29
+ margin-bottom: 15px;
30
+ padding: 12px 16px;
31
+ border-radius: 15px;
32
+ position: relative;
33
+ }
34
+ .bubble-user {
35
+ align-self: flex-end;
36
+ background: #007bff;
37
+ color: white;
38
+ border-bottom-right-radius: 2px;
39
+ }
40
+ .bubble-ai {
41
+ align-self: flex-start;
42
+ background: #e9ecef;
43
+ color: #212529;
44
+ border-bottom-left-radius: 2px;
45
+ }
46
+ .input-box {
47
+ background: white;
48
+ padding: 15px;
49
+ border-top: 1px solid #dee2e6;
50
+ display: flex;
51
+ gap: 10px;
52
+ }
53
+ .memory-section {
54
+ margin-bottom: 25px;
55
+ }
56
+ .memory-section h6 {
57
+ font-weight: 700;
58
+ color: #495057;
59
+ text-transform: uppercase;
60
+ font-size: 0.8rem;
61
+ margin-bottom: 10px;
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 5px;
65
+ }
66
+ .fact-item {
67
+ background: #fff3cd;
68
+ padding: 8px 12px;
69
+ border-radius: 8px;
70
+ font-size: 0.9rem;
71
+ margin-bottom: 8px;
72
+ border-left: 4px solid #ffc107;
73
+ }
74
+ .summary-text {
75
+ font-size: 0.9rem;
76
+ color: #6c757d;
77
+ line-height: 1.5;
78
+ }
79
+ </style>
80
+
81
+ <div class="workspace-container">
82
+ <!-- 집필 구역 (에디터/채팅) -->
83
+ <div class="editor-area">
84
+ <div class="d-flex justify-content-between align-items-center mb-4">
85
+ <h4><i class="fas fa-edit me-2"></i>{{ project.title }} <small class="text-muted">({{ project.mode }})</small></h4>
86
+ <div id="loadingStatus" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
87
+ </div>
88
+
89
+ <div id="chatWindow" class="d-flex flex-column flex-grow-1" style="min-height: 300px;">
90
+ <div class="chat-bubble bubble-ai">
91
+ 안녕하세요! 소설 <b>"{{ project.title }}"</b>의 집필을 도와드릴 AI 어시스턴트입니다.
92
+ 캐릭터 설정이나 다음 전개에 대해 무엇이든 물어보세요.
93
+ </div>
94
+ </div>
95
+
96
+ <div class="input-box mt-auto">
97
+ <textarea id="userInput" class="form-control" rows="2" placeholder="에이전트에게 지시하거나 질문하세요... (Enter로 전송)"></textarea>
98
+ <button id="sendBtn" class="btn btn-primary" onclick="sendMessage()">
99
+ <i class="fas fa-paper-plane"></i>
100
+ </button>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- 메모리 사이드바 -->
105
+ <div class="memory-sidebar">
106
+ <div class="memory-section">
107
+ <h6><i class="fas fa-users text-warning"></i> 캐릭터 및 설정 (Mem0)</h6>
108
+ <div id="factContainer">
109
+ <div class="text-muted small">설정을 불러오는 중...</div>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="memory-section">
114
+ <h6><i class="fas fa-history text-info"></i> 스토리 문맥 요약 (Zep)</h6>
115
+ <div id="summaryContainer" class="summary-text italic">
116
+ 요약을 불러오는 중...
117
+ </div>
118
+ </div>
119
+
120
+ <div class="mt-auto">
121
+ <button class="btn btn-sm btn-outline-secondary w-100" onclick="refreshMemory()">
122
+ <i class="fas fa-sync-alt me-1"></i> 동기화
123
+ </button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <script>
129
+ const projectId = "{{ project.project_id }}";
130
+
131
+ async function sendMessage() {
132
+ const input = document.getElementById('userInput');
133
+ const message = input.value.trim();
134
+ if (!message) return;
135
+
136
+ appendBubble(message, 'user');
137
+ input.value = '';
138
+
139
+ document.getElementById('loadingStatus').classList.remove('d-none');
140
+
141
+ try {
142
+ const response = await fetch(`/creation/api/chat/${projectId}`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ message })
146
+ });
147
+ const data = await response.json();
148
+ appendBubble(data.reply, 'ai');
149
+ } catch (error) {
150
+ appendBubble("오류가 발생했습니다.", 'ai');
151
+ } finally {
152
+ document.getElementById('loadingStatus').classList.add('d-none');
153
+ refreshMemory();
154
+ }
155
+ }
156
+
157
+ function appendBubble(content, role) {
158
+ const win = document.getElementById('chatWindow');
159
+ const div = document.createElement('div');
160
+ div.className = `chat-bubble bubble-${role}`;
161
+ div.innerHTML = content.replace(/\n/g, '<br>');
162
+ win.appendChild(div);
163
+ win.scrollTop = win.scrollHeight;
164
+ }
165
+
166
+ async function refreshMemory() {
167
+ // 실제로는 API를 통해 Zep/Mem0 데이터를 실시간 조회해야 함
168
+ // 여기서는 목업 데이터를 표시하거나 에이전트 응답 시 함께 갱신
169
+ try {
170
+ // 임시 목업 데이터
171
+ document.getElementById('factContainer').innerHTML = `
172
+ <div class="fact-item">주인공: 김철수 (25세, 검사)</div>
173
+ <div class="fact-item">배경: 2024년 가상 서울</div>
174
+ `;
175
+ document.getElementById('summaryContainer').innerText = "최근 철수는 강남역 근처에서 의문의 포탈을 발견했습니다.";
176
+ } catch (e) {}
177
+ }
178
+
179
+ document.getElementById('userInput').addEventListener('keypress', function(e) {
180
+ if (e.key === 'Enter' && !e.shiftKey) {
181
+ e.preventDefault();
182
+ sendMessage();
183
+ }
184
+ });
185
+
186
+ // 초기 로드
187
+ document.addEventListener('DOMContentLoaded', refreshMemory);
188
+ </script>
189
+ {% endblock %}
190
+