feat: v3.2 나머지 미완성 기능 구현

[Instagram Reels] Phase 2 완성
- instagram_bot.py: publish_reels() 추가 (MP4 → Reels API)
  - upload_video_container(), wait_for_video_ready() 구현
  - 로컬 경로 → 공개 URL 자동 변환 (image_host.get_public_video_url())
- scheduler.py: job_distribute_instagram_reels() 추가 (10:30)
- image_host.py: get_public_video_url() + 로컬 비디오 서버 추가
  - VIDEO_HOST_BASE_URL 환경변수 지원 (Tailscale/CDN)

[writer_bot.py] 신규 — 독립 실행형 글쓰기 봇
- api_content.py manual-write 엔드포인트에서 subprocess 호출 가능
- run_pending(): 오늘 날짜 미처리 글감 자동 처리
- run_from_topic(): 직접 주제 지정
- run_from_file(): JSON 파일 지정
- CLI: python bots/writer_bot.py [--topic "..." | --file path.json | --limit N]

[보조 시스템 신규] v3.1 CLI + Assist 모드
- blog.cmd: venv Python 경유 Windows 런처
- blog_runtime.py + runtime_guard.py: 실행 진입점 + venv 검증
- blog_engine_cli.py: 대시보드 API 기반 CLI (blog status, blog review 등)
- bots/assist_bot.py: URL 기반 수동 어시스트 파이프라인
- dashboard/backend/api_assist.py + frontend/Assist.jsx: 수동모드 탭

[engine_loader.py] v3.1 개선
- OpenClawWriter: --json 플래그 + payloads 파싱 + plain text 폴백
- ClaudeWebWriter: Playwright 쿠키 세션 (Cloudflare 차단으로 현재 비활성)
- GeminiWebWriter: gemini-webapi 비공식 클라이언트

[scheduler.py] v3.1 개선
- _call_openclaw(): 플레이스홀더 → EngineLoader 실제 호출
- _build_openclaw_prompt(): 구조화된 HTML 원고 프롬프트
- data/originals/: 원본 article JSON 저장 경로 추가

[설정/환경] 정비
- .env.example: SEEDANCE/ELEVENLABS/GEMINI/RUNWAY 복원
  + VIDEO_HOST_BASE_URL, GEMINI_WEB_* , REMOTE_CLAUDE_POLLING_ENABLED 추가
- scripts/setup.bat: data/originals, outputs, assist, novels, config/novels
  디렉토리 생성 + 폰트 다운로드 + blog.cmd 기반 Task Scheduler 등록
- requirements.txt: fastapi, uvicorn, python-multipart 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-28 17:12:39 +09:00
parent 213f57b52d
commit 392c2e13f1
26 changed files with 2296 additions and 98 deletions
+5 -3
View File
@@ -52,7 +52,7 @@ dashboard/
```bash
cd D:/workspace/blog-writer
pip install fastapi uvicorn python-dotenv
venv\Scripts\python.exe -m pip install -r requirements.txt
```
### 프론트엔드 의존성 설치
@@ -69,6 +69,8 @@ npm install
- **프로덕션**: `start.bat` 더블클릭
- **개발 모드**: `start_dev.bat` 더블클릭
두 스크립트 모두 프로젝트 `venv\Scripts\python.exe`가 없으면 즉시 중단합니다.
### Linux/Mac
```bash
@@ -84,7 +86,7 @@ bash dashboard/start.sh dev
```bash
# 터미널 1 — 백엔드
cd D:/workspace/blog-writer
python -m uvicorn dashboard.backend.server:app --port 8080 --reload
venv\Scripts\python.exe blog_runtime.py server --reload
# 터미널 2 — 프론트엔드 (개발)
cd D:/workspace/blog-writer/dashboard/frontend
@@ -117,6 +119,6 @@ npm run build
```bash
# 백엔드를 0.0.0.0으로 바인딩하면 Tailscale IP로 접속 가능
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
venv\Scripts\python.exe blog_runtime.py server
# 접속: http://<tailscale-ip>:8080
```
+116
View File
@@ -0,0 +1,116 @@
"""
dashboard/backend/api_assist.py
수동(어시스트) 모드 API
"""
import threading
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
BASE_DIR = Path(__file__).parent.parent.parent
router = APIRouter()
# assist_bot을 지연 임포트 (서버 기동 시 오류 방지)
def _bot():
import sys
if str(BASE_DIR) not in sys.path:
sys.path.insert(0, str(BASE_DIR))
from bots import assist_bot
return assist_bot
@router.post("/assist/session")
async def create_session(payload: dict):
"""URL을 받아 새 어시스트 세션을 생성하고 파이프라인을 시작한다."""
url = payload.get('url', '').strip()
if not url.startswith('http'):
raise HTTPException(status_code=400, detail="유효한 URL을 입력하세요.")
bot = _bot()
session = bot.create_session(url)
t = threading.Thread(
target=bot.run_pipeline,
args=(session['session_id'],),
daemon=True,
)
t.start()
return session
@router.get("/assist/sessions")
async def list_sessions():
return _bot().list_sessions()
@router.get("/assist/session/{sid}")
async def get_session(sid: str):
bot = _bot()
# inbox 자동 스캔
bot.scan_inbox(sid)
session = bot.load_session(sid)
if not session:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
return session
@router.post("/assist/session/{sid}/upload")
async def upload_asset(sid: str, file: UploadFile = File(...), asset_type: str = Form("image")):
"""에셋 파일 직접 업로드."""
bot = _bot()
session = bot.load_session(sid)
if not session:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
assets_dir = bot.session_dir(sid) / 'assets'
assets_dir.mkdir(parents=True, exist_ok=True)
# 파일명 충돌 방지
fname = file.filename or f"asset_{datetime.now().strftime('%H%M%S')}"
dest = assets_dir / fname
dest.write_bytes(await file.read())
ext = dest.suffix.lower()
detected_type = 'video' if ext in bot.VIDEO_EXTENSIONS else 'image'
session.setdefault('assets', []).append({
'type': detected_type,
'path': str(dest),
'filename': fname,
'added_at': datetime.now().isoformat(),
})
bot.save_session(session)
return {"ok": True, "filename": fname, "type": detected_type}
@router.delete("/assist/session/{sid}/asset/{filename}")
async def delete_asset(sid: str, filename: str):
bot = _bot()
session = bot.load_session(sid)
if not session:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
session['assets'] = [a for a in session.get('assets', []) if a['filename'] != filename]
bot.save_session(session)
# 파일도 삭제
p = bot.session_dir(sid) / 'assets' / filename
p.unlink(missing_ok=True)
return {"ok": True}
@router.get("/assist/inbox")
async def inbox_info():
"""inbox 폴더 경로 및 파일 목록 반환."""
bot = _bot()
inbox = bot.INBOX_DIR
files = [f.name for f in inbox.iterdir() if f.is_file()] if inbox.exists() else []
return {"path": str(inbox), "files": files, "count": len(files)}
@router.delete("/assist/session/{sid}")
async def delete_session(sid: str):
import shutil
bot = _bot()
p = bot.session_dir(sid)
if p.exists():
shutil.rmtree(p)
return {"ok": True}
+25 -13
View File
@@ -4,13 +4,14 @@ Content 탭 API — 칸반 보드, 승인/거부, 수동 트리거
"""
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from runtime_guard import project_python_path, run_with_project_python
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "data"
@@ -115,8 +116,17 @@ async def reject_content(item_id: str):
@router.post("/manual-write")
async def manual_write(req: WriteRequest):
"""collector_bot + writer_bot 수동 트리거"""
python = sys.executable
bots_dir = BASE_DIR / "bots"
python = project_python_path()
if not python.exists():
raise HTTPException(
status_code=500,
detail=(
f"프로젝트 가상환경 Python이 없습니다: {python}. "
"venv 생성 후 requirements.txt를 설치하세요."
),
)
results = []
@@ -124,8 +134,8 @@ async def manual_write(req: WriteRequest):
collector = bots_dir / "collector_bot.py"
if collector.exists():
try:
result = subprocess.run(
[python, str(collector)],
result = run_with_project_python(
[str(collector)],
capture_output=True,
text=True,
timeout=120,
@@ -135,22 +145,23 @@ async def manual_write(req: WriteRequest):
results.append({
"step": "collector",
"success": result.returncode == 0,
"python": str(python),
"output": result.stdout[-500:] if result.stdout else "",
"error": result.stderr[-300:] if result.stderr else "",
})
except subprocess.TimeoutExpired:
results.append({"step": "collector", "success": False, "error": "타임아웃"})
results.append({"step": "collector", "success": False, "python": str(python), "error": "타임아웃"})
except Exception as e:
results.append({"step": "collector", "success": False, "error": str(e)})
results.append({"step": "collector", "success": False, "python": str(python), "error": str(e)})
else:
results.append({"step": "collector", "success": False, "error": "파일 없음"})
results.append({"step": "collector", "success": False, "python": str(python), "error": "파일 없음"})
# writer_bot 실행
writer = bots_dir / "writer_bot.py"
if writer.exists():
try:
result = subprocess.run(
[python, str(writer)],
result = run_with_project_python(
[str(writer)],
capture_output=True,
text=True,
timeout=300,
@@ -160,14 +171,15 @@ async def manual_write(req: WriteRequest):
results.append({
"step": "writer",
"success": result.returncode == 0,
"python": str(python),
"output": result.stdout[-500:] if result.stdout else "",
"error": result.stderr[-300:] if result.stderr else "",
})
except subprocess.TimeoutExpired:
results.append({"step": "writer", "success": False, "error": "타임아웃"})
results.append({"step": "writer", "success": False, "python": str(python), "error": "타임아웃"})
except Exception as e:
results.append({"step": "writer", "success": False, "error": str(e)})
results.append({"step": "writer", "success": False, "python": str(python), "error": str(e)})
else:
results.append({"step": "writer", "success": False, "error": "파일 없음"})
results.append({"step": "writer", "success": False, "python": str(python), "error": "파일 없음"})
return {"results": results}
return {"python": str(python), "results": results}
+9
View File
@@ -8,6 +8,13 @@ dashboard/backend/server.py
import os
from pathlib import Path
from runtime_guard import ensure_project_runtime
ensure_project_runtime(
"dashboard server",
["fastapi", "uvicorn", "python-dotenv"],
)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
@@ -23,6 +30,7 @@ from dashboard.backend import (
api_tools,
api_cost,
api_logs,
api_assist,
)
app = FastAPI(title="The 4th Path — Control Panel", version="1.0.0")
@@ -51,6 +59,7 @@ app.include_router(api_connections.router, prefix="/api")
app.include_router(api_tools.router, prefix="/api")
app.include_router(api_cost.router, prefix="/api")
app.include_router(api_logs.router, prefix="/api")
app.include_router(api_assist.router, prefix="/api")
@app.get("/api/health")
async def health():
+3 -1
View File
@@ -1,15 +1,17 @@
import React, { useState } from 'react'
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText } from 'lucide-react'
import { LayoutDashboard, FileText, BarChart2, BookOpen, Settings, ScrollText, UserCheck } from 'lucide-react'
import Overview from './pages/Overview.jsx'
import Content from './pages/Content.jsx'
import Analytics from './pages/Analytics.jsx'
import Novel from './pages/Novel.jsx'
import SettingsPage from './pages/Settings.jsx'
import Logs from './pages/Logs.jsx'
import Assist from './pages/Assist.jsx'
const TABS = [
{ id: 'overview', label: '개요', icon: LayoutDashboard, component: Overview },
{ id: 'content', label: '콘텐츠', icon: FileText, component: Content },
{ id: 'assist', label: '수동모드', icon: UserCheck, component: Assist },
{ id: 'analytics', label: '분석', icon: BarChart2, component: Analytics },
{ id: 'novel', label: '소설', icon: BookOpen, component: Novel },
{ id: 'settings', label: '설정', icon: Settings, component: SettingsPage },
+416
View File
@@ -0,0 +1,416 @@
import { useState, useEffect, useRef } from 'react'
import {
Link2, Upload, FolderOpen, RefreshCw, Trash2,
CheckCircle, Clock, AlertCircle, Loader, ChevronDown,
ChevronUp, Copy, Image, Video, FileText, Plus, X
} from 'lucide-react'
const API = ''
const STATUS_COLOR = {
pending: '#888880',
fetching: '#4a5abf',
generating: '#c8a84e',
awaiting: '#c8a84e',
assembling: '#4a5abf',
ready: '#3a7d5c',
error: '#bf3a3a',
}
const STATUS_ICON = {
pending: <Clock size={14} />,
fetching: <Loader size={14} className="spin" />,
generating: <Loader size={14} className="spin" />,
awaiting: <Upload size={14} />,
assembling: <Loader size={14} className="spin" />,
ready: <CheckCircle size={14} />,
error: <AlertCircle size={14} />,
}
function CopyBtn({ text }) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<button onClick={copy} style={{
background: 'none', border: '1px solid #333', borderRadius: 4,
color: copied ? '#3a7d5c' : '#888', cursor: 'pointer',
padding: '2px 8px', fontSize: 11, display: 'flex', alignItems: 'center', gap: 4
}}>
<Copy size={11} /> {copied ? '복사됨' : '복사'}
</button>
)
}
function PromptCard({ prompt, index }) {
return (
<div style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 8, padding: 12, marginBottom: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#c8a84e', fontWeight: 600 }}>
<Image size={11} style={{ display: 'inline', marginRight: 4 }} />
이미지 #{index + 1} {prompt.purpose}
</span>
<CopyBtn text={prompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{prompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{prompt.en}
</div>
</div>
)
}
function AssetDropZone({ sessionId, onUploaded }) {
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const inputRef = useRef()
const handleFiles = async (files) => {
setUploading(true)
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
fd.append('asset_type', file.type.startsWith('video') ? 'video' : 'image')
await fetch(`${API}/api/assist/session/${sessionId}/upload`, { method: 'POST', body: fd })
}
setUploading(false)
onUploaded()
}
return (
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles([...e.dataTransfer.files]) }}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? '#c8a84e' : '#333'}`,
borderRadius: 8, padding: '20px 16px', textAlign: 'center',
cursor: 'pointer', background: dragging ? '#1a1a0a' : 'transparent',
transition: 'all 0.2s',
}}
>
<input ref={inputRef} type="file" multiple accept="image/*,video/*"
style={{ display: 'none' }}
onChange={e => handleFiles([...e.target.files])} />
{uploading
? <><Loader size={20} style={{ color: '#c8a84e', marginBottom: 6 }} /><div style={{ color: '#888', fontSize: 12 }}>업로드 ...</div></>
: <>
<Upload size={20} style={{ color: '#555', marginBottom: 6 }} />
<div style={{ color: '#888', fontSize: 12 }}>이미지 / 영상 드래그 드롭 또는 클릭하여 선택</div>
<div style={{ color: '#555', fontSize: 11, marginTop: 4 }}>JPG, PNG, WebP, MP4, MOV 지원</div>
</>
}
</div>
)
}
function SessionCard({ session: initial, onDelete }) {
const [session, setSession] = useState(initial)
const [open, setOpen] = useState(initial.status === 'awaiting')
const [refreshing, setRefreshing] = useState(false)
const refresh = async () => {
setRefreshing(true)
const r = await fetch(`${API}/api/assist/session/${session.session_id}`)
if (r.ok) setSession(await r.json())
setRefreshing(false)
}
// 처리 중이면 자동 폴링
useEffect(() => {
const active = ['pending', 'fetching', 'generating']
if (!active.includes(session.status)) return
const t = setInterval(refresh, 3000)
return () => clearInterval(t)
}, [session.status])
const removeAsset = async (filename) => {
await fetch(`${API}/api/assist/session/${session.session_id}/asset/${filename}`, { method: 'DELETE' })
refresh()
}
const color = STATUS_COLOR[session.status] || '#888'
const icon = STATUS_ICON[session.status]
const prompts = session.prompts || {}
const imagePrompts = prompts.image_prompts || []
const videoPrompt = prompts.video_prompt || null
const narration = prompts.narration_script || ''
const assets = session.assets || []
return (
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, marginBottom: 12, overflow: 'hidden'
}}>
{/* 헤더 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 16px', cursor: 'pointer',
}} onClick={() => setOpen(o => !o)}>
<div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#e0e0d8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.title || session.url}
</div>
<div style={{ fontSize: 11, color: '#555', marginTop: 2 }}>
{new Date(session.created_at).toLocaleString('ko-KR')}
{' · '}
<span style={{ color }}>{session.status_label || session.status}</span>
{assets.length > 0 && <span style={{ color: '#3a7d5c' }}> · 에셋 {assets.length}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button onClick={e => { e.stopPropagation(); refresh() }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<RefreshCw size={13} className={refreshing ? 'spin' : ''} />
</button>
<button onClick={e => { e.stopPropagation(); onDelete(session.session_id) }}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 4 }}>
<Trash2 size={13} />
</button>
{open ? <ChevronUp size={14} color="#555" /> : <ChevronDown size={14} color="#555" />}
</div>
</div>
{/* 본문 */}
{open && (
<div style={{ borderTop: '1px solid #1a1a20', padding: 16 }}>
{session.status === 'error' && (
<div style={{ background: '#1a0808', border: '1px solid #bf3a3a', borderRadius: 6, padding: 10, marginBottom: 12, fontSize: 12, color: '#bf3a3a' }}>
{session.error}
</div>
)}
{session.body_preview && (
<div style={{ fontSize: 12, color: '#666', marginBottom: 14, lineHeight: 1.6 }}>
{session.body_preview}
</div>
)}
{/* 프롬프트 섹션 */}
{imagePrompts.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Image size={13} /> 이미지 프롬프트
</div>
{imagePrompts.map((p, i) => <PromptCard key={i} prompt={p} index={i} />)}
</div>
)}
{videoPrompt && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Video size={13} /> 영상 프롬프트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: '#4a5abf' }}>Sora / Runway / Veo</span>
<CopyBtn text={videoPrompt.en} />
</div>
<div style={{ fontSize: 12, color: '#b0b0a8', marginBottom: 4 }}>
<span style={{ color: '#666', marginRight: 6 }}>KO</span>{videoPrompt.ko}
</div>
<div style={{ fontSize: 12, color: '#e0e0d8' }}>
<span style={{ color: '#666', marginRight: 6 }}>EN</span>{videoPrompt.en}
</div>
</div>
</div>
)}
{narration && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={13} /> 나레이션 스크립트
</div>
<div style={{ background: '#0f0f14', border: '1px solid #2a2a32', borderRadius: 8, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
<CopyBtn text={narration} />
</div>
<div style={{ fontSize: 12, color: '#e0e0d8', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{narration}
</div>
</div>
</div>
)}
{/* 에셋 업로드 */}
{['awaiting', 'ready', 'generating'].includes(session.status) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<Upload size={13} /> 에셋 제공
</div>
<AssetDropZone sessionId={session.session_id} onUploaded={refresh} />
</div>
)}
{/* 등록된 에셋 */}
{assets.length > 0 && (
<div>
<div style={{ fontSize: 12, color: '#888', fontWeight: 600, marginBottom: 8 }}>
등록된 에셋 ({assets.length})
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{assets.map((a, i) => (
<div key={i} style={{
background: '#0f0f14', border: '1px solid #2a2a32',
borderRadius: 6, padding: '6px 10px', fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6
}}>
{a.type === 'video' ? <Video size={11} color="#4a5abf" /> : <Image size={11} color="#3a7d5c" />}
<span style={{ color: '#b0b0a8', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.filename}
</span>
<button onClick={() => removeAsset(a.filename)}
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', padding: 0 }}>
<X size={11} />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default function Assist() {
const [url, setUrl] = useState('')
const [sessions, setSessions] = useState([])
const [loading, setLoading] = useState(false)
const [inboxPath, setInboxPath] = useState('')
const load = async () => {
const [s, i] = await Promise.all([
fetch(`${API}/api/assist/sessions`).then(r => r.json()).catch(() => []),
fetch(`${API}/api/assist/inbox`).then(r => r.json()).catch(() => ({})),
])
setSessions(s)
setInboxPath(i.path || '')
}
useEffect(() => { load() }, [])
const submit = async () => {
if (!url.trim()) return
setLoading(true)
await fetch(`${API}/api/assist/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim() }),
})
setUrl('')
await load()
setLoading(false)
}
const deleteSession = async (sid) => {
if (!confirm('이 세션을 삭제하시겠습니까?')) return
await fetch(`${API}/api/assist/session/${sid}`, { method: 'DELETE' })
load()
}
return (
<div style={{ padding: '24px 28px', maxWidth: 820, margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#e0e0d8', margin: 0 }}>
수동(어시스트) 모드
</h2>
<p style={{ fontSize: 13, color: '#666', marginTop: 6 }}>
직접 작성한 블로그 URL을 입력하면 시스템이 이미지·영상 프롬프트를 생성합니다.
생성한 에셋을 업로드하면 쇼츠 조립·배포 파이프라인으로 연결됩니다.
</p>
</div>
{/* URL 입력 */}
<div style={{
background: '#111116', border: '1px solid #222228',
borderRadius: 10, padding: 16, marginBottom: 16
}}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8, fontWeight: 600 }}>
<Link2 size={12} style={{ display: 'inline', marginRight: 6 }} />
블로그 URL
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={url}
onChange={e => setUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="https://www.the4thpath.com/2026/03/..."
style={{
flex: 1, background: '#0a0a0d', border: '1px solid #333',
borderRadius: 6, padding: '8px 12px', color: '#e0e0d8',
fontSize: 13, outline: 'none',
}}
/>
<button
onClick={submit}
disabled={loading || !url.trim()}
style={{
background: loading ? '#333' : '#c8a84e',
color: loading ? '#888' : '#0a0a0d',
border: 'none', borderRadius: 6, padding: '8px 16px',
fontWeight: 700, fontSize: 13, cursor: loading ? 'default' : 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
{loading ? <><Loader size={13} className="spin" /> 분석 </> : <><Plus size={13} /> 분석 시작</>}
</button>
</div>
</div>
{/* inbox 폴더 안내 */}
{inboxPath && (
<div style={{
background: '#0d0d10', border: '1px solid #1e2a1e',
borderRadius: 8, padding: '10px 14px', marginBottom: 20,
display: 'flex', alignItems: 'center', gap: 10
}}>
<FolderOpen size={14} color="#3a7d5c" />
<div>
<span style={{ fontSize: 11, color: '#3a7d5c', fontWeight: 600 }}>폴더 드롭 경로</span>
<span style={{ fontSize: 11, color: '#666', marginLeft: 8 }}>{inboxPath}</span>
</div>
<div style={{ fontSize: 11, color: '#555', marginLeft: 'auto' }}>
파일명 8자리에 세션 ID를 포함하면 자동 연결됩니다
</div>
</div>
)}
{/* 세션 목록 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#888', fontWeight: 600 }}>
세션 목록 ({sessions.length})
</span>
<button onClick={load} style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<RefreshCw size={12} /> 새로고침
</button>
</div>
{sessions.length === 0
? <div style={{ textAlign: 'center', color: '#444', padding: '40px 0', fontSize: 13 }}>
아직 세션이 없습니다. URL을 입력해 시작하세요.
</div>
: sessions.map(s => (
<SessionCard key={s.session_id} session={s} onDelete={deleteSession} />
))
}
<style>{`
.spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
`}</style>
</div>
)
}
+7 -11
View File
@@ -9,18 +9,14 @@ echo ================================================
set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%..
:: Python 가상환경 활성화
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
echo [*] 가상환경 활성화...
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe"
if not exist "%PYTHON%" (
echo [ERROR] Missing project virtualenv Python: %PYTHON%
echo Run scripts\setup.bat or create venv and install requirements first.
pause
exit /b 1
)
:: 백엔드 의존성 확인
echo [*] FastAPI 의존성 확인...
pip install fastapi uvicorn python-dotenv --quiet 2>nul
:: 프론트엔드 의존성 설치
if not exist "%SCRIPT_DIR%frontend\node_modules" (
echo [*] npm 패키지 설치 중...
@@ -47,6 +43,6 @@ echo 종료하려면 이 창을 닫으세요.
echo.
cd /d "%PROJECT_ROOT%"
python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080
"%PYTHON%" blog_runtime.py server
pause
+7 -9
View File
@@ -9,16 +9,14 @@ echo ================================================
set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%..
:: Python 가상환경 활성화
if exist "%PROJECT_ROOT%\venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\venv\Scripts\activate.bat"
) else if exist "%PROJECT_ROOT%\.venv\Scripts\activate.bat" (
call "%PROJECT_ROOT%\.venv\Scripts\activate.bat"
set "PYTHON=%PROJECT_ROOT%\venv\Scripts\python.exe"
if not exist "%PYTHON%" (
echo [ERROR] Missing project virtualenv Python: %PYTHON%
echo Run scripts\setup.bat or create venv and install requirements first.
pause
exit /b 1
)
:: 백엔드 의존성 확인
pip install fastapi uvicorn python-dotenv --quiet 2>nul
:: 프론트엔드 의존성 설치
if not exist "%SCRIPT_DIR%frontend\node_modules" (
echo [*] npm 패키지 설치 중...
@@ -27,7 +25,7 @@ if not exist "%SCRIPT_DIR%frontend\node_modules" (
)
echo [*] 백엔드 시작 중...
start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && python -m uvicorn dashboard.backend.server:app --host 0.0.0.0 --port 8080 --reload"
start "FastAPI Backend" cmd /k "cd /d %PROJECT_ROOT% && %PYTHON% blog_runtime.py server --reload"
echo [*] 프론트엔드 개발 서버 시작 중...
start "Vite Frontend" cmd /k "cd /d %SCRIPT_DIR%frontend && npm run dev"