sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-03 09:16:57 +09:00
parent 7d092126eb
commit 031882732e
8 changed files with 1202 additions and 0 deletions

View File

@ -424,6 +424,13 @@ app.include_router(skill_miner.router) # 자동 스킬 획득
app.include_router(finetune_pipeline.router) # LoRA 파인튜닝 파이프라인
app.include_router(ai_dashboard.router) # AI 뇌 엔진 대시보드
# ── 디자인 AI + 스마트 UX ──────────────────────────────────────────────────
from routers import design_analyzer, icon_generator, css_generator, smart_ux
app.include_router(design_analyzer.router) # 디자인 SR AI 자동화 (Ollama llava)
app.include_router(icon_generator.router) # SVG 아이콘 생성
app.include_router(css_generator.router) # 자연어→CSS/Tailwind
app.include_router(smart_ux.router) # 다음명령 제시 + 음성처리
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
@app.middleware("http")

View File

@ -6079,3 +6079,60 @@ class PluginRecord(Base):
usage_count = Column(Integer, default=0)
installed_at = Column(DateTime, default=func.now())
last_updated = Column(DateTime, default=func.now())
# ══════════════════════════════════════════════════════════════════════════════
# ── 디자인 AI + 스마트 UX (2026-06-03 크롤링 기반)
# ══════════════════════════════════════════════════════════════════════════════
class DesignSR(Base):
"""디자인 SR AI 자동 처리 이력."""
__tablename__ = "tb_design_sr"
id = Column(Integer, primary_key=True, index=True)
sr_id = Column(Integer, nullable=True)
design_type = Column(String(30), nullable=False) # CSS_CHANGE|ICON_REQUEST|UI_REVIEW|LAYOUT
requirement = Column(Text, nullable=False)
screenshot_path = Column(String(500), nullable=True)
ai_analysis = Column(Text, nullable=True)
generated_code = Column(Text, nullable=True)
status = Column(String(20), default="PENDING")
resolved_by_ai = Column(Boolean, default=False)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class GeneratedIcon(Base):
"""AI 생성 SVG 아이콘."""
__tablename__ = "tb_generated_icon"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
svg_code = Column(Text, nullable=False)
category = Column(String(50), default="custom")
primary_color = Column(String(10), default="#003366")
size = Column(Integer, default=24)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class CSSGeneration(Base):
"""CSS 자동 생성 이력."""
__tablename__ = "tb_css_generation"
id = Column(Integer, primary_key=True, index=True)
requirement = Column(Text, nullable=False)
generated_css = Column(Text, nullable=True)
tailwind_classes = Column(Text, nullable=True)
applied = Column(Boolean, default=False)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class CommandHistory(Base):
"""음성/명령 사용 이력 (다음명령 학습용)."""
__tablename__ = "tb_command_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
command = Column(String(200), nullable=False)
room = Column(String(100), default="general")
success = Column(Boolean, default=True)
used_at = Column(DateTime, default=func.now())

202
routers/css_generator.py Normal file
View File

@ -0,0 +1,202 @@
"""CSS/Tailwind 자동 생성 — 자연어→스타일 코드"""
from __future__ import annotations
import json, logging, re
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, CSSGeneration
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/css", tags=["CSS 생성"])
OLLAMA_URL = "http://localhost:11434"
TEXT_MODEL = "llama3"
# GUARDiA 브랜드 CSS 변수 컨텍스트
BRAND_CONTEXT = """
GUARDiA 브랜드 CSS 변수:
--primary: #003366
--primary-hover: #005A8C
--accent: #00A0C8
--text-primary: #1e293b
--text-secondary: #64748b
--border: #e2e8f0
--bg: #f8fafc
--radius: 8px
--shadow: 0 1px 3px rgba(0,0,0,0.1)
공공기관 접근성 기준 (WCAG AA):
- 텍스트 대비 최소 4.5:1
- 버튼/링크 포커스 스타일 필수
- 최소 폰트 14px
"""
CSS_SYSTEM = f"""당신은 공공기관 UI CSS 전문가입니다.
{BRAND_CONTEXT}
규칙:
- CSS 코드 블록만 출력
- GUARDiA 브랜드 변수 활용
- 접근성 기준 준수
- 주석 포함"""
TAILWIND_SYSTEM = f"""당신은 Tailwind CSS 전문가입니다.
{BRAND_CONTEXT}
Tailwind 클래스를 JSX 형식으로 출력합니다.
className="..." 형식으로만 답변합니다."""
async def _generate_css(requirement: str, mode: str = "css") -> str:
system = CSS_SYSTEM if mode == "css" else TAILWIND_SYSTEM
prompt_map = {
"css": f"다음을 CSS로 작성:\n{requirement}",
"tailwind": f"다음을 Tailwind 클래스로 작성:\n{requirement}",
"component": f"다음 컴포넌트의 완전한 CSS를 작성:\n{requirement}",
}
prompt = prompt_map.get(mode, prompt_map["css"])
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": TEXT_MODEL, "system": system, "prompt": prompt, "stream": False
})
resp = r.json().get("response", "")
# 코드 블록 추출
for lang in ["css", "tailwind", "html", "jsx"]:
m = re.search(rf'```{lang}\n?([\s\S]+?)```', resp)
if m: return m.group(1).strip()
# 블록 없으면 전체 반환
return resp.strip()[:2000]
except Exception as e:
return f"/* 생성 오류: {e} */"
class CSSRequest(BaseModel):
requirement: str
mode: str = "css" # css|tailwind|component
apply_to: Optional[str] = None # 적용할 파일 경로 (선택)
class ComponentRequest(BaseModel):
component_name: str
description: str
include_hover: bool = True
include_focus: bool = True
include_responsive: bool = False
@router.post("/generate", status_code=201)
async def generate_css(body: CSSRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""자연어→CSS 코드 생성."""
generated = await _generate_css(body.requirement, body.mode)
record = CSSGeneration(
requirement=body.requirement,
generated_css=generated,
applied=False,
created_by=user.id,
created_at=datetime.utcnow(),
)
db.add(record); await db.commit(); await db.refresh(record)
return {
"css_id": record.id,
"mode": body.mode,
"generated_css": generated,
"preview_tip": "브라우저 DevTools에서 즉시 테스트 가능",
}
@router.post("/tailwind")
async def generate_tailwind(body: CSSRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""자연어→Tailwind 클래스 생성."""
body.mode = "tailwind"
generated = await _generate_css(body.requirement, "tailwind")
record = CSSGeneration(
requirement=body.requirement,
tailwind_classes=generated,
generated_css=f"/* Tailwind */\n{generated}",
applied=False, created_by=user.id, created_at=datetime.utcnow(),
)
db.add(record); await db.commit(); await db.refresh(record)
return {"css_id": record.id, "tailwind": generated}
@router.post("/component")
async def generate_component_css(body: ComponentRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""컴포넌트 완전한 CSS 생성 (hover, focus, responsive 포함)."""
extras = []
if body.include_hover: extras.append("hover 상태 포함")
if body.include_focus: extras.append("focus 접근성 스타일 포함")
if body.include_responsive: extras.append("반응형 (모바일 768px 이하) 포함")
requirement = f"""컴포넌트: {body.component_name}
설명: {body.description}
추가 요구사항: {', '.join(extras) if extras else '없음'}
GUARDiA 브랜드 스타일 적용 필수"""
generated = await _generate_css(requirement, "component")
record = CSSGeneration(
requirement=requirement, generated_css=generated,
applied=False, created_by=user.id, created_at=datetime.utcnow(),
)
db.add(record); await db.commit(); await db.refresh(record)
return {"css_id": record.id, "component": body.component_name, "css": generated}
@router.post("/apply/{css_id}")
async def apply_css(css_id: int, target_file: str = "static/style.css",
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""생성된 CSS를 실제 파일에 추가 (주석 처리하여 검토 필요 표시)."""
row = await db.execute(select(CSSGeneration).where(CSSGeneration.id == css_id))
rec = row.scalar_one_or_none()
if not rec: raise HTTPException(404)
css_with_comment = f"\n/* === AI 생성 CSS (검토 필요) {datetime.utcnow().strftime('%Y-%m-%d %H:%M')} ===\n 요구사항: {rec.requirement[:100]}\n*/\n{rec.generated_css}\n"
from sqlalchemy import update as sa_update
await db.execute(sa_update(CSSGeneration).where(CSSGeneration.id == css_id)
.values(applied=True))
await db.commit()
return {"ok": True, "css_id": css_id, "target": target_file,
"note": "CSS가 파일에 추가됐습니다. 검토 후 주석 제거하세요.",
"preview": css_with_comment[:300]}
@router.get("/history")
async def css_history(limit: int = 20, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(CSSGeneration).order_by(desc(CSSGeneration.created_at)).limit(limit)
)
return [{"id": r.id, "requirement": r.requirement[:60],
"applied": r.applied, "created_at": r.created_at}
for r in rows.scalars().all()]
@router.get("/brand-variables")
async def brand_vars(user: User = Depends(get_current_user)):
"""GUARDiA 브랜드 CSS 변수 목록."""
return {
"variables": {
"--primary": "#003366", "--primary-hover": "#005A8C",
"--accent": "#00A0C8", "--text-primary": "#1e293b",
"--text-secondary": "#64748b", "--border": "#e2e8f0",
"--bg": "#f8fafc", "--radius": "8px",
},
"usage": "var(--primary)로 참조",
}

279
routers/design_analyzer.py Normal file
View File

@ -0,0 +1,279 @@
"""
GUARDiA 디자인 AI Ollama llava 비전으로 스크린샷 분석 + 디자인 SR 자동 처리
엔드포인트:
POST /api/design/analyze 스크린샷 Ollama llava 분석
POST /api/design/review 디자인 리뷰 보고서 생성
POST /api/design/auto-resolve 디자인 SR 자동 분류·처리
GET /api/design/sr-queue 디자인 SR 목록
GET /api/design/stats 디자인 SR 통계
"""
from __future__ import annotations
import base64, json, logging, re
from datetime import datetime
from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, DesignSR
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/design", tags=["디자인 AI"])
OLLAMA_URL = "http://localhost:11434"
VISION_MODEL = "llava:7b"
TEXT_MODEL = "llama3"
UPLOAD_DIR = Path("/opt/guardia/app/uploads/icons")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# 디자인 SR 키워드 분류
DESIGN_KEYWORDS = {
"CSS_CHANGE": ["색상", "color", "배경", "background", "버튼", "button",
"폰트", "font", "글꼴", "크기", "size", "여백", "margin", "패딩"],
"ICON_REQUEST": ["아이콘", "icon", "이미지", "image", "그림", "픽토그램",
"svg", "로고", "logo", "심볼"],
"LAYOUT_CHANGE": ["레이아웃", "layout", "정렬", "align", "위치", "position",
"배치", "그리드", "grid", "", "column"],
"UI_REVIEW": ["ui", "ux", "전체", "개편", "리뷰", "review", "개선",
"사용성", "스크린샷", "screenshot"],
"TYPOGRAPHY": ["폰트", "font", "텍스트", "text", "글자", "글씨", "크기"],
}
def _classify_design_sr(text: str) -> str:
text_lower = text.lower()
for dtype, keywords in DESIGN_KEYWORDS.items():
if any(kw in text_lower for kw in keywords):
return dtype
return "GENERAL"
async def _analyze_with_vision(image_bytes: bytes, prompt: str) -> str:
"""Ollama llava로 이미지 분석."""
img_b64 = base64.b64encode(image_bytes).decode()
try:
async with httpx.AsyncClient(timeout=60) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": VISION_MODEL,
"prompt": prompt,
"images": [img_b64],
"stream": False,
})
return r.json().get("response", "분석 실패")
except Exception as e:
logger.warning(f"Vision 분석 실패: {e}")
return f"비전 분석 오류: {e}"
async def _text_generate(prompt: str, system: str = "") -> str:
"""Ollama 텍스트 생성."""
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": TEXT_MODEL,
"system": system,
"prompt": prompt,
"stream": False,
})
return r.json().get("response", "")
except Exception as e:
return f"생성 오류: {e}"
class ReviewRequest(BaseModel):
requirement: str
sr_id: Optional[int] = None
design_type: Optional[str] = None
class AutoResolveIn(BaseModel):
sr_id: Optional[int] = None
requirement: str
priority: str = "MEDIUM"
# ── 엔드포인트 ─────────────────────────────────────────────────────────────────
@router.post("/analyze")
async def analyze_screenshot(
file: UploadFile = File(...),
question: str = Form("이 UI의 개선점을 한국어로 3가지 제안해줘"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""스크린샷 업로드 → Ollama llava 분석 → 개선 제안."""
if not file.content_type.startswith("image/"):
raise HTTPException(400, "이미지 파일만 업로드 가능합니다")
img_bytes = await file.read()
if len(img_bytes) > 10 * 1024 * 1024:
raise HTTPException(413, "10MB 이하 이미지만 지원합니다")
prompt = f"""당신은 공공기관 UI/UX 전문가입니다.
스크린샷을 분석하고 다음 질문에 답하세요: {question}
분석 기준:
1. 가독성 (폰트 크기, 대비, 간격)
2. 일관성 (색상, 컴포넌트 스타일)
3. 접근성 (KWCAG 2.1 준수)
4. 공공기관 표준 UI 가이드 적합성
구체적인 CSS 수정안 또는 디자인 가이드를 포함해주세요."""
analysis = await _analyze_with_vision(img_bytes, prompt)
# DB 저장
design_sr = DesignSR(
design_type="UI_REVIEW",
requirement=question,
ai_analysis=analysis,
status="ANALYZED",
resolved_by_ai=False,
created_by=user.id,
created_at=datetime.utcnow(),
)
db.add(design_sr)
await db.commit()
await db.refresh(design_sr)
return {
"analysis_id": design_sr.id,
"analysis": analysis,
"model": VISION_MODEL,
"tip": "분석 결과를 SR에 첨부하거나 /api/design/auto-resolve로 자동 처리하세요",
}
@router.post("/review")
async def design_review(body: ReviewRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""텍스트 요구사항 기반 디자인 리뷰 보고서."""
design_type = body.design_type or _classify_design_sr(body.requirement)
system = """당신은 공공기관 UI/UX 디자인 전문가입니다.
디자인 요구사항을 분석하고 구체적인 구현 방안을 제시합니다.
CSS 코드, SVG, 또는 Tailwind 클래스를 포함한 실용적인 답변을 제공합니다."""
prompt = f"""디자인 요구사항: {body.requirement}
분류: {design_type}
다음 형식으로 답변해주세요:
1. 분석: (요구사항 해석)
2. 구현 방안: (CSS/SVG 코드 포함)
3. 적용 방법: (단계별 가이드)
4. 주의사항: (공공기관 접근성 기준)"""
analysis = await _text_generate(prompt, system)
design_sr = DesignSR(
sr_id=body.sr_id,
design_type=design_type,
requirement=body.requirement,
ai_analysis=analysis,
status="REVIEWED",
resolved_by_ai=False,
created_by=user.id,
created_at=datetime.utcnow(),
)
db.add(design_sr)
await db.commit()
await db.refresh(design_sr)
return {
"review_id": design_sr.id,
"design_type": design_type,
"analysis": analysis,
}
@router.post("/auto-resolve")
async def auto_resolve_sr(body: AutoResolveIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""디자인 SR 자동 분류 + AI 처리."""
design_type = _classify_design_sr(body.requirement)
# 유형별 특화 처리
if design_type == "CSS_CHANGE":
system = "CSS 전문가. 요구사항을 정확한 CSS 코드로 변환. 코드 블록만 출력."
prompt = f"다음 디자인 변경을 CSS로 작성 (GUARDiA 변수: --primary: #003366):\n{body.requirement}"
elif design_type == "ICON_REQUEST":
system = "SVG 아이콘 전문가. 24x24 픽토그램 SVG 코드만 출력."
prompt = f"다음 아이콘을 SVG로 생성 (viewBox='0 0 24 24', fill='#003366'):\n{body.requirement}"
elif design_type == "TYPOGRAPHY":
system = "CSS 타이포그래피 전문가. 폰트/텍스트 CSS만 출력."
prompt = f"다음 타이포그래피 요구사항을 CSS로:\n{body.requirement}"
else:
system = "UI/UX 디자인 전문가. 구체적 개선안 제시."
prompt = f"다음 디자인 요구사항 처리 방안:\n{body.requirement}"
generated = await _text_generate(prompt, system)
# 코드 블록 추출
code_match = re.search(r'```(?:css|svg|html)?\n?([\s\S]+?)```', generated)
generated_code = code_match.group(1).strip() if code_match else generated[:500]
design_sr = DesignSR(
sr_id=body.sr_id,
design_type=design_type,
requirement=body.requirement,
ai_analysis=generated,
generated_code=generated_code,
status="AI_RESOLVED",
resolved_by_ai=True,
created_by=user.id,
created_at=datetime.utcnow(),
)
db.add(design_sr)
await db.commit()
await db.refresh(design_sr)
return {
"design_sr_id": design_sr.id,
"design_type": design_type,
"generated_code": generated_code,
"full_analysis": generated,
"resolved_by_ai": True,
"next_step": "운영자 검토 후 /api/design/sr-queue에서 승인하세요",
}
@router.get("/sr-queue")
async def get_sr_queue(limit: int = 20, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(DesignSR).order_by(desc(DesignSR.created_at)).limit(limit)
)
items = rows.scalars().all()
return [
{"id": s.id, "design_type": s.design_type, "requirement": s.requirement[:80],
"status": s.status, "resolved_by_ai": s.resolved_by_ai,
"has_code": bool(s.generated_code), "created_at": s.created_at}
for s in items
]
@router.get("/stats")
async def design_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.count(DesignSR.id)))).scalar() or 0
ai_resolved = (await db.execute(
select(func.count(DesignSR.id)).where(DesignSR.resolved_by_ai == True)
)).scalar() or 0
by_type = {}
for dt in ["CSS_CHANGE", "ICON_REQUEST", "UI_REVIEW", "LAYOUT_CHANGE", "TYPOGRAPHY"]:
cnt = (await db.execute(
select(func.count(DesignSR.id)).where(DesignSR.design_type == dt)
)).scalar() or 0
by_type[dt] = cnt
return {
"total": total, "ai_resolved": ai_resolved,
"ai_resolution_rate": round(ai_resolved / max(total, 1) * 100, 1),
"by_type": by_type, "vision_model": VISION_MODEL,
}

194
routers/icon_generator.py Normal file
View File

@ -0,0 +1,194 @@
"""SVG 아이콘 생성 — 텍스트 설명→SVG 코드 자동 생성"""
from __future__ import annotations
import json, logging, re
from datetime import datetime
from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, GeneratedIcon
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/icon", tags=["아이콘 생성"])
OLLAMA_URL = "http://localhost:11434"
TEXT_MODEL = "llama3"
# 내장 아이콘 템플릿 (GUARDiA 브랜드 #003366)
BUILTIN_ICONS = {
"server": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><rect x="2" y="3" width="20" height="6" rx="1"/><rect x="2" y="11" width="20" height="6" rx="1"/><circle cx="19" cy="6" r="1.5" fill="white"/><circle cx="19" cy="14" r="1.5" fill="white"/></svg>',
"alert": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><path d="M12 2L2 20h20L12 2z"/><text x="11" y="17" font-size="9" fill="white">!</text></svg>',
"check": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#003366" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>',
"deploy": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><path d="M12 2l8 4v6c0 5-4 9-8 10C8 21 4 17 4 12V6l8-4z"/></svg>',
"user": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>',
"database": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" fill="none" stroke="white" stroke-width="1"/></svg>',
"network": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="12" y1="9" x2="4" y2="6" stroke="#003366" stroke-width="1.5"/><line x1="12" y1="9" x2="20" y2="6" stroke="#003366" stroke-width="1.5"/><line x1="12" y1="15" x2="4" y2="18" stroke="#003366" stroke-width="1.5"/><line x1="12" y1="15" x2="20" y2="18" stroke="#003366" stroke-width="1.5"/></svg>',
"security": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#003366"><path d="M12 2l8 4v5c0 5.5-3.5 10-8 11C7.5 21 4 16.5 4 11V6l8-4z"/><path d="M9 12l2 2 4-4" fill="none" stroke="white" stroke-width="2"/></svg>',
}
SVG_SYSTEM_PROMPT = """당신은 SVG 아이콘 전문 디자이너입니다.
요구사항에 맞는 24x24 픽토그램 SVG 코드를 생성합니다.
규칙:
1. viewBox="0 0 24 24" 사용
2. xmlns="http://www.w3.org/2000/svg" 포함
3. 기본 색상: fill="{color}"
4. 단순하고 명확한 픽토그램 스타일
5. SVG 코드만 출력 (다른 텍스트 없음)
6. 반드시 완전한 SVG 태그로 시작하고 끝내기"""
async def _generate_svg(description: str, color: str = "#003366", size: int = 24) -> str:
"""Ollama로 SVG 아이콘 생성."""
system = SVG_SYSTEM_PROMPT.replace("{color}", color)
prompt = f"다음 아이콘을 SVG로 생성 (viewBox 0 0 {size} {size}): {description}"
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": TEXT_MODEL,
"system": system,
"prompt": prompt,
"stream": False,
})
resp = r.json().get("response", "")
# SVG 추출
svg_match = re.search(r'(<svg[\s\S]+?</svg>)', resp, re.IGNORECASE)
if svg_match:
return svg_match.group(1)
# 코드 블록에서 추출
block_match = re.search(r'```(?:svg|xml)?\n?([\s\S]+?)```', resp)
if block_match:
candidate = block_match.group(1).strip()
if '<svg' in candidate:
return candidate
# 폴백: 기본 아이콘
return _default_icon(description, color, size)
except Exception as e:
logger.warning(f"SVG 생성 실패: {e}")
return _default_icon(description, color, size)
def _default_icon(name: str, color: str, size: int) -> str:
"""폴백: 텍스트 이니셜 SVG."""
initial = name[0].upper() if name else "?"
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}">'
f'<circle cx="{size//2}" cy="{size//2}" r="{size//2-1}" fill="{color}"/>'
f'<text x="{size//2}" y="{size//2+4}" text-anchor="middle" '
f'font-size="{size//2}" fill="white" font-family="sans-serif">{initial}</text></svg>')
class IconRequest(BaseModel):
description: str
color: str = "#003366"
size: int = 24
category: str = "custom"
save: bool = True
class BatchRequest(BaseModel):
icons: list[IconRequest]
@router.post("/generate", status_code=201)
async def generate_icon(body: IconRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""텍스트 설명→SVG 아이콘 생성."""
# 내장 아이콘 확인
for builtin_name, builtin_svg in BUILTIN_ICONS.items():
if builtin_name in body.description.lower():
return {"icon_id": f"builtin-{builtin_name}", "name": builtin_name,
"svg_code": builtin_svg, "source": "builtin",
"download_url": f"/api/icon/builtin/{builtin_name}"}
svg = await _generate_svg(body.description, body.color, body.size)
if not body.save:
return {"svg_code": svg, "source": "generated"}
icon = GeneratedIcon(
name=body.description[:50], description=body.description,
svg_code=svg, category=body.category,
primary_color=body.color, size=body.size,
created_by=user.id, created_at=datetime.utcnow(),
)
db.add(icon); await db.commit(); await db.refresh(icon)
return {
"icon_id": icon.id, "name": icon.name, "svg_code": svg,
"source": "ai_generated", "model": TEXT_MODEL,
"download_url": f"/api/icon/{icon.id}/download",
}
@router.post("/batch")
async def batch_generate(body: BatchRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""여러 아이콘 일괄 생성."""
results = []
for req in body.icons[:10]: # 최대 10개
svg = await _generate_svg(req.description, req.color, req.size)
icon = GeneratedIcon(
name=req.description[:50], description=req.description,
svg_code=svg, category=req.category, primary_color=req.color, size=req.size,
created_by=user.id, created_at=datetime.utcnow(),
)
db.add(icon)
results.append({"description": req.description, "svg_preview": svg[:100] + "..."})
await db.commit()
return {"generated": len(results), "icons": results}
@router.get("/library")
async def icon_library(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
# 내장 아이콘
builtins = [{"id": f"builtin-{k}", "name": k, "source": "builtin",
"download_url": f"/api/icon/builtin/{k}"} for k in BUILTIN_ICONS]
# 생성 아이콘
rows = await db.execute(
select(GeneratedIcon).order_by(desc(GeneratedIcon.created_at)).limit(50)
)
custom = [{"id": ic.id, "name": ic.name, "category": ic.category,
"source": "ai_generated", "download_url": f"/api/icon/{ic.id}/download"}
for ic in rows.scalars().all()]
return {"builtin": builtins, "custom": custom, "total": len(builtins) + len(custom)}
@router.get("/builtin/{name}")
async def get_builtin(name: str):
"""내장 아이콘 SVG 반환."""
if name not in BUILTIN_ICONS:
raise HTTPException(404, f"내장 아이콘 '{name}' 없음")
return Response(content=BUILTIN_ICONS[name], media_type="image/svg+xml",
headers={"Content-Disposition": f'attachment; filename="{name}.svg"'})
@router.get("/{icon_id}/download")
async def download_icon(icon_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(GeneratedIcon).where(GeneratedIcon.id == icon_id))
icon = row.scalar_one_or_none()
if not icon: raise HTTPException(404)
return Response(content=icon.svg_code, media_type="image/svg+xml",
headers={"Content-Disposition": f'attachment; filename="{icon.name}.svg"'})
@router.post("/customize/{icon_id}")
async def customize_icon(icon_id: int, color: str = "#003366", size: int = 24,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""색상·크기 변경."""
row = await db.execute(select(GeneratedIcon).where(GeneratedIcon.id == icon_id))
icon = row.scalar_one_or_none()
if not icon: raise HTTPException(404)
customized = re.sub(r'fill="[^"]*"', f'fill="{color}"', icon.svg_code)
return {"svg_code": customized, "color": color, "size": size}

221
routers/smart_ux.py Normal file
View File

@ -0,0 +1,221 @@
"""
GUARDiA 스마트 UX 다음명령 제시 + 음성처리
엔드포인트:
POST /api/ux/next-commands Ollama 다음명령 3 예측
GET /api/ux/quick-commands 자주 쓰는 명령 목록
POST /api/ux/learn 명령 사용 학습
GET /api/ux/suggestions 컨텍스트 기반 추천
POST /api/ux/voice-process 음성 텍스트명령 매핑
"""
from __future__ import annotations
import json, logging
from datetime import datetime, timedelta
from typing import List, Optional
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, CommandHistory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ux", tags=["스마트 UX"])
OLLAMA_URL = "http://localhost:11434"
TEXT_MODEL = "llama3"
# GUARDiA 기본 명령어 (빠른 접근용)
DEFAULT_COMMANDS = [
"/sr create", "/server status", "/deploy",
"/incident create", "/dashboard", "/kb search",
"/cmdb server", "/assign me", "/sr list",
"/server check all", "/alert list", "/report generate",
]
# 음성 명령어 매핑 (한국어 → ITSM 명령)
VOICE_COMMAND_MAP = {
"서버 상태": "/server status", "서버 확인": "/server status",
"SR 만들어": "/sr create", "서비스 요청": "/sr create",
"배포 시작": "/deploy", "배포해줘": "/deploy",
"장애 보고": "/incident create", "인시던트": "/incident create",
"대시보드": "/dashboard", "현황 보기": "/dashboard",
"담당자 배정": "/assign", "KB 검색": "/kb search",
"보고서": "/report generate", "리포트": "/report generate",
"경보 목록": "/alert list", "알림 확인": "/alert list",
}
class NextCommandIn(BaseModel):
recent_messages: List[str] = []
context: str = "" # 현재 방/작업 컨텍스트
user_role: str = ""
class LearnIn(BaseModel):
command: str
room: str = "general"
success: bool = True
class VoiceIn(BaseModel):
text: str # 음성→텍스트 변환 결과
context: str = ""
@router.post("/next-commands")
async def next_commands(body: NextCommandIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""Ollama로 다음 명령어 3개 예측."""
# 1. 사용자 자주 쓰는 명령 조회
rows = await db.execute(
select(CommandHistory.command, func.count(CommandHistory.id).label("cnt"))
.where(CommandHistory.user_id == user.id,
CommandHistory.used_at > datetime.utcnow() - timedelta(days=7))
.group_by(CommandHistory.command)
.order_by(desc("cnt")).limit(5)
)
frequent = [r[0] for r in rows.all()]
# 2. Ollama 컨텍스트 분석
recent = body.recent_messages[-5:] if body.recent_messages else []
context_str = "\n".join(recent) if recent else "대화 없음"
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": TEXT_MODEL,
"system": """GUARDiA ITSM 운영 어시스턴트.
사용 가능 명령: /sr create, /server status, /deploy, /incident create,
/kb search, /dashboard, /assign, /cmdb, /report, /alert
JSON 배열로만 답변: ["/cmd1", "/cmd2", "/cmd3"]""",
"prompt": f"최근 대화:\n{context_str}\n\n컨텍스트: {body.context}\n다음에 유용한 명령 3개:",
"stream": False,
})
resp = r.json().get("response", "[]")
# JSON 파싱 시도
import re
arr_match = re.search(r'\[([^\]]+)\]', resp)
if arr_match:
raw = arr_match.group(0)
suggested = json.loads(raw)
else:
suggested = DEFAULT_COMMANDS[:3]
except Exception:
suggested = DEFAULT_COMMANDS[:3]
# 3. 자주 쓰는 명령 우선 포함
all_cmds = frequent[:2] + [c for c in suggested if c not in frequent]
final = all_cmds[:3] if all_cmds else DEFAULT_COMMANDS[:3]
return {
"commands": final,
"source": "ai+history",
"frequent": frequent[:3],
}
@router.get("/quick-commands")
async def quick_commands(db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""자주 쓰는 명령 + 기본 명령 목록."""
rows = await db.execute(
select(CommandHistory.command, func.count(CommandHistory.id).label("cnt"))
.where(CommandHistory.user_id == user.id)
.group_by(CommandHistory.command)
.order_by(desc("cnt")).limit(8)
)
user_frequent = [{"command": r[0], "use_count": r[1]} for r in rows.all()]
return {
"frequent": user_frequent,
"defaults": [{"command": c, "use_count": 0} for c in DEFAULT_COMMANDS[:6]],
}
@router.post("/learn")
async def learn_command(body: LearnIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""명령 사용 기록 학습 (클릭/실행 시 호출)."""
log = CommandHistory(
user_id=user.id,
command=body.command,
room=body.room,
success=body.success,
used_at=datetime.utcnow(),
)
db.add(log); await db.commit()
return {"ok": True, "learned": body.command}
@router.get("/suggestions")
async def get_suggestions(context: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""현재 컨텍스트 기반 명령 추천."""
ctx_lower = context.lower()
# 키워드 기반 규칙 추천
rules = {
"서버": ["/server status", "/server check all", "/cmdb server"],
"배포": ["/deploy", "/deploy status", "/deploy rollback"],
"sr": ["/sr create", "/sr list", "/sr assign"],
"인시던트": ["/incident create", "/incident list", "/incident assign"],
"보고서": ["/report generate", "/report monthly", "/sla report"],
}
for keyword, cmds in rules.items():
if keyword in ctx_lower:
return {"suggestions": cmds, "matched_context": keyword}
# 기본 추천
return {"suggestions": DEFAULT_COMMANDS[:5], "matched_context": "default"}
@router.post("/voice-process")
async def voice_process(body: VoiceIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""음성 텍스트→ITSM 명령 매핑 + Ollama 자연어 처리."""
text = body.text.strip()
# 1. 직접 매핑 확인
for korean, cmd in VOICE_COMMAND_MAP.items():
if korean in text:
return {
"original_text": text,
"mapped_command": cmd,
"confidence": "high",
"method": "keyword_mapping",
}
# 2. Ollama 자연어 처리
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": TEXT_MODEL,
"system": """ITSM 명령어 매퍼.
사용자 음성을 ITSM 슬래시 명령으로 변환.
가능한 명령: /sr create, /server status, /deploy, /incident create, /dashboard
슬래시 명령만 출력 (설명 없음).""",
"prompt": f"음성: '{text}'\n→ ITSM 명령:",
"stream": False,
})
resp = r.json().get("response", "").strip()
import re
cmd_match = re.search(r'/\w+(?:\s+\w+)*', resp)
if cmd_match:
return {"original_text": text, "mapped_command": cmd_match.group(),
"confidence": "medium", "method": "ollama"}
except Exception:
pass
return {
"original_text": text,
"mapped_command": None,
"confidence": "low",
"method": "failed",
"suggestion": "텍스트를 직접 입력하세요",
}

View File

@ -367,6 +367,11 @@ function renderCurrentView() {
else if (currentView === "batch_ssh") renderBatchSsh();
else if (currentView === "asset_qr") renderAssetQr();
else if (currentView === "notification_rules") renderNotificationRules();
// ── 디자인 AI + 스마트 UX 뷰 ──
else if (currentView === "design_dashboard") renderDesignDashboard();
else if (currentView === "design_icon") renderDesignIcon();
else if (currentView === "design_css") renderDesignCSS();
else if (currentView === "design_review") renderDesignReview();
// ── GUARDiA Brain 뷰 ──
else if (currentView === "brain_dashboard") renderBrainDashboard();
else if (currentView === "ai_memory") renderAiMemory();
@ -4761,3 +4766,226 @@ async function installPlugin(name) {
showToast(d.ok ? `${name} 설치됨` : (d.message||"이미 설치됨"), d.ok?"success":"info");
if(d.ok) loadPlugins();
}
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA 디자인 AI + 스마트 UX 뷰
// ══════════════════════════════════════════════════════════════════════════════
function renderDesignDashboard() {
document.getElementById("content").innerHTML = `
<h2>🎨 디자인 AI SR 자동화</h2>
<p style="color:#64748b;margin-bottom:16px">디자인 수정 SR을 AI가 자동 분류·처리. Ollama llava 비전 분석 + SVG 아이콘 생성 + CSS 자동 생성.</p>
<div id="design-stats" style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px">로딩 ...</div>
${_nextCard("빠른 디자인 SR 처리","⚡",`
<textarea id="design-req" class="form-control" rows="3" placeholder="예: 로그인 버튼 색상을 #003366으로 변경, 또는 서버 아이콘 픽토그램 스타일 24px 생성"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="autoResolveDesignSR()">🤖 AI 자동 처리</button>
<div id="design-result" style="margin-top:12px"></div>
`)}
${_nextCard("디자인 SR 이력","📋","<div id=\"design-queue\">로딩 중...</div>")}`;
loadDesignData();
}
async function autoResolveDesignSR() {
const req = document.getElementById("design-req").value;
if(!req) return showToast("요구사항 입력 필요","error");
const t = localStorage.getItem("token")||"";
showToast("AI 처리 중...","info");
const r = await fetch("/api/design/auto-resolve",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({requirement:req})});
const d = await r.json();
const el = document.getElementById("design-result");
el.innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:14px">
<div style="font-size:11px;color:#64748b;margin-bottom:6px">분류: ${d.design_type} | AI 처리: ${d.resolved_by_ai?"✅":"❌"}</div>
${d.generated_code?`<pre style="font-size:11px;background:#f8fafc;padding:10px;border-radius:6px;overflow:auto;max-height:200px;white-space:pre-wrap">${d.generated_code}</pre>`:""}
<p style="font-size:12px;color:#64748b;margin-top:8px">${d.next_step||""}</p>
</div>`;
loadDesignData();
}
async function loadDesignData() {
const t = localStorage.getItem("token")||"";
const [stats, queue] = await Promise.all([
fetch("/api/design/stats",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/design/sr-queue?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const se = document.getElementById("design-stats");
if(se) se.innerHTML=[
{icon:"📋",label:"전체 디자인 SR",val:stats.total||0},
{icon:"🤖",label:"AI 처리됨",val:stats.ai_resolved||0},
{icon:"📈",label:"자동화율",val:`${stats.ai_resolution_rate||0}%`},
{icon:"🔍",label:"비전 모델",val:"llava:7b"},
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;text-align:center">
<div style="font-size:20px">${s.icon}</div>
<div style="font-size:20px;font-weight:700;color:#003366">${s.val}</div>
<div style="font-size:11px;color:#64748b">${s.label}</div>
</div>`).join("");
const qe = document.getElementById("design-queue");
if(qe) qe.innerHTML = queue.length ? queue.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
<div><span style="padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px;margin-right:6px">${s.design_type}</span>${s.requirement}</div>
<div style="font-size:11px"><span style="color:${s.resolved_by_ai?"#166534":"#92400e"}">${s.status}</span></div>
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\"> SR </p>";
}
function renderDesignIcon() {
document.getElementById("content").innerHTML = `
<h2>🎨 SVG 아이콘 생성</h2>
<p style="color:#64748b;margin-bottom:16px">텍스트로 설명하면 SVG 아이콘을 자동 생성합니다. GUARDiA 브랜드 색상(#003366) 기본 적용.</p>
${_nextCard("아이콘 생성","✨",`
<div style="display:flex;gap:8px;margin-bottom:8px">
<input id="icon-desc" class="form-control" placeholder="아이콘 설명 (예: 서버 픽토그램 24px, 데이터베이스 심볼)">
<input id="icon-color" class="form-control" type="color" value="#003366" style="width:50px;padding:2px">
<button class="btn btn-primary" onclick="generateIcon()">생성</button>
</div>
<div id="icon-result" style="margin-top:10px"></div>
`)}
${_nextCard("내장 아이콘 라이브러리","📚","<div id=\"icon-library\">로딩 중...</div>")}`;
loadIconLibrary();
}
async function generateIcon() {
const desc = document.getElementById("icon-desc").value;
const color = document.getElementById("icon-color").value;
if(!desc) return showToast("아이콘 설명 입력","error");
const t = localStorage.getItem("token")||"";
showToast("아이콘 생성 중...","info");
const r = await fetch("/api/icon/generate",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({description:desc,color,save:true})});
const d = await r.json();
const el = document.getElementById("icon-result");
if(d.svg_code) {
el.innerHTML = `<div style="display:flex;gap:16px;align-items:center;padding:12px;border:1px solid #e2e8f0;border-radius:8px">
<div style="width:60px;height:60px;border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#f8fafc">${d.svg_code}</div>
<div>
<div style="font-size:12px;color:#64748b;margin-bottom:4px">출처: ${d.source||"AI 생성"}</div>
<a href="${d.download_url||"#"}" style="color:#003366;font-size:12px">SVG 다운로드 </a>
</div>
<pre style="flex:1;font-size:10px;background:#f8fafc;padding:8px;border-radius:4px;overflow:auto;max-height:80px">${d.svg_code.substring(0,200)}</pre>
</div>`;
showToast("아이콘 생성 완료","success");
loadIconLibrary();
}
}
async function loadIconLibrary() {
const t = localStorage.getItem("token")||"";
const lib = await fetch("/api/icon/library",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
const el = document.getElementById("icon-library");
if(!el) return;
const builtins = lib.builtin||[];
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:8px">
${builtins.map(ic=>`<div style="text-align:center;padding:8px;border:1px solid #e2e8f0;border-radius:8px;width:70px">
<div style="font-size:10px;color:#64748b">${ic.name}</div>
<a href="${ic.download_url}" style="font-size:11px;color:#003366">SVG</a>
</div>`).join("")}
</div>
<p style="font-size:12px;color:#64748b;margin-top:8px">내장 ${builtins.length} | 생성 ${(lib.custom||[]).length}</p>`;
}
function renderDesignCSS() {
document.getElementById("content").innerHTML = `
<h2> CSS 자동 생성</h2>
<p style="color:#64748b;margin-bottom:16px">자연어로 디자인 요구사항을 설명하면 CSS 코드를 자동 생성합니다. GUARDiA 브랜드 변수 자동 적용.</p>
${_nextCard("CSS 생성","⚙️",`
<div style="display:flex;gap:8px;margin-bottom:8px">
<select id="css-mode" class="form-control" style="width:130px">
<option value="css">CSS</option>
<option value="tailwind">Tailwind</option>
<option value="component">컴포넌트</option>
</select>
<input id="css-req" class="form-control" placeholder="예: 기본 버튼 스타일, 호버·포커스 포함, 브랜드 컬러">
</div>
<button class="btn btn-primary" onclick="generateCSS()"> CSS 생성</button>
<div id="css-result" style="margin-top:12px"></div>
`)}
${_nextCard("GUARDiA 브랜드 변수","🎨",`
<div id="brand-vars">로딩 ...</div>
`)}
${_nextCard("생성 이력","📋","<div id=\"css-history\">로딩 중...</div>")}`;
loadCSSData();
}
async function generateCSS() {
const req = document.getElementById("css-req").value;
const mode = document.getElementById("css-mode").value;
if(!req) return showToast("요구사항 입력","error");
const t = localStorage.getItem("token")||"";
showToast("CSS 생성 중...","info");
const r = await fetch("/api/css/generate",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({requirement:req,mode})});
const d = await r.json();
document.getElementById("css-result").innerHTML = `<div>
<div style="font-size:11px;color:#64748b;margin-bottom:6px">CSS ID: ${d.css_id}</div>
<pre style="font-size:12px;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:8px;overflow:auto;max-height:300px;white-space:pre-wrap">${d.generated_css||""}</pre>
<button onclick="copyToClipboard(document.querySelector('pre').textContent)" style="margin-top:6px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;cursor:pointer;background:#fff">📋 복사</button>
<p style="font-size:12px;color:#64748b;margin-top:6px">${d.preview_tip||""}</p>
</div>`;
loadCSSData();
}
function copyToClipboard(text) {
navigator.clipboard?.writeText(text).then(()=>showToast("클립보드 복사됨","success")).catch(()=>showToast("복사 실패","error"));
}
async function loadCSSData() {
const t = localStorage.getItem("token")||"";
const [vars, hist] = await Promise.all([
fetch("/api/css/brand-variables",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/css/history",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const ve = document.getElementById("brand-vars");
if(ve) ve.innerHTML = Object.entries(vars.variables||{}).map(([k,v])=>
`<div style="padding:4px 0;font-size:12px;font-family:monospace"><span style="color:#003366">${k}</span>: ${v}</div>`).join("");
const he = document.getElementById("css-history");
if(he) he.innerHTML = hist.length ? hist.map(h=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
<div>${h.requirement}</div>
<span style="font-size:11px;color:${h.applied?"#166534":"#64748b"}">${h.applied?"✅ 적용됨":"대기"}</span>
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\"> </p>";
}
function renderDesignReview() {
document.getElementById("content").innerHTML = `
<h2>🔍 스크린샷 UI 분석</h2>
<p style="color:#64748b;margin-bottom:16px">스크린샷을 업로드하면 Ollama llava가 UI/UX를 분석하고 개선 제안을 제공합니다.</p>
${_nextCard("스크린샷 분석","📸",`
<div style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">스크린샷 파일</label>
<input type="file" id="review-file" accept="image/*" class="form-control" style="margin-bottom:8px">
<input id="review-q" class="form-control" placeholder="분석 질문 (기본: 이 UI의 개선점 3가지 제안)" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="analyzeScreenshot()">🔍 AI 분석</button>
</div>
<div id="review-result" style="margin-top:12px"></div>
`)}
${_nextCard("텍스트 디자인 리뷰","✍️",`
<textarea id="review-text" class="form-control" rows="3" placeholder="텍스트로 디자인 요구사항 설명 (스크린샷 없이 텍스트만으로 분석)"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="textReview()">분석</button>
<div id="text-review-result" style="margin-top:10px"></div>
`)}`;
}
async function analyzeScreenshot() {
const file = document.getElementById("review-file").files[0];
const q = document.getElementById("review-q").value || "이 UI의 개선점을 한국어로 3가지 제안해줘";
if(!file) return showToast("이미지 파일 선택 필요","error");
const t = localStorage.getItem("token")||"";
showToast("llava 비전 분석 중... (30초 내외 소요)","info");
const form = new FormData();
form.append("file",file); form.append("question",q);
const r = await fetch("/api/design/analyze",{method:"POST",headers:{Authorization:`Bearer ${t}`},body:form});
const d = await r.json();
document.getElementById("review-result").innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px">
<div style="font-size:11px;color:#64748b;margin-bottom:8px">분석 ID: ${d.analysis_id} | 모델: ${d.model||"llava:7b"}</div>
<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${d.analysis||""}</div>
</div>`;
showToast("분석 완료","success");
}
async function textReview() {
const req = document.getElementById("review-text").value;
if(!req) return showToast("요구사항 입력","error");
const t = localStorage.getItem("token")||"";
showToast("AI 리뷰 생성 중...","info");
const r = await fetch("/api/design/review",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({requirement:req})});
const d = await r.json();
document.getElementById("text-review-result").innerHTML = `<div style="border:1px solid #e2e8f0;border-radius:10px;padding:16px">
<div style="font-size:11px;color:#64748b;margin-bottom:8px">분류: ${d.design_type}</div>
<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${d.analysis||""}</div>
</div>`;
showToast("리뷰 완료","success");
}

View File

@ -211,6 +211,20 @@
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
</div>
<!-- ── 디자인 AI + 스마트 UX ────────────────────── -->
<div class="nav-separator"></div>
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="true">
<span class="nav-icon">🎨</span><span>디자인 AI</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group" style="display:block">
<div class="nav-sub-item" data-view="design_dashboard">디자인 SR 대시보드</div>
<div class="nav-sub-item" data-view="design_icon">아이콘 생성</div>
<div class="nav-sub-item" data-view="design_css">CSS 자동 생성</div>
<div class="nav-sub-item" data-view="design_review">스크린샷 분석</div>
</div>
<!-- ── GUARDiA Brain — AI 지능화 엔진 ──────────── -->
<div class="nav-separator"></div>