SOY NV AI commited on
Commit ·
42090e1
1
Parent(s): 6a0cd71
feat: ?뱀냼??李쎌옉 ??쒕낫??諛??묒뾽怨듦컙 UI 援ы쁽
Browse files- app/__init__.py +3 -0
- app/agent/agent.py +67 -0
- app/agent/deps.py +13 -0
- app/models/project_config.py +18 -0
- app/routers/creation.py +82 -0
- app/routes.py +9 -0
- app/services/memory_service.py +32 -0
- app/services/rag_service.py +16 -0
- requirements.txt +4 -0
- templates/novel_dashboard.html +132 -0
- templates/novel_workspace.html +190 -0
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 |
+
|