feat: 업무 작성 AI 할 일 추천

- POST /ai/suggest-checklist — 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내
  카테고리별(필수/추가/보안) 할 일 항목을 JSON 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502)
- AiSuggestController + SuggestChecklistDto + AiService.suggestChecklist/parseSuggestions
- 프론트: TaskCreatePage 액션바 "취소 / AI 추천 / 지시 보내기" + 체크리스트 추천 패널
  (카테고리별 항목 클릭 추가·전체 추가·중복 스킵), useAi.suggestChecklist(silent)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 15:05:34 +09:00
parent 51b608efeb
commit 0976a5057b
8 changed files with 478 additions and 1 deletions
+286
View File
@@ -10,8 +10,10 @@ import { useMemberStore } from '@/stores/member.store'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useTask } from '@/composables/useTask'
import { useAi } from '@/composables/useAi'
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { ChecklistSuggestionGroup } from '@/types/ai'
const route = useRoute()
const router = useRouter()
@@ -25,6 +27,7 @@ const memberStore = useMemberStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const taskApi = useTask()
const ai = useAi()
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): User {
@@ -151,6 +154,52 @@ function addItem() {
newItem.value = ''
}
// --- AI 할 일 추천 ---
const aiSuggesting = ref(false)
const aiSuggestions = ref<ChecklistSuggestionGroup[] | null>(null)
const aiError = ref<string | null>(null)
const canRequestAi = computed(() => !aiSuggesting.value && title.value.trim().length > 0)
// 저장소 설명 + 제목/내용을 분석해 카테고리별 할 일 추천을 받는다
async function requestAiSuggestions() {
if (!canRequestAi.value) return
aiSuggesting.value = true
aiError.value = null
aiSuggestions.value = null
try {
const content = contentText.value
.split('\n')
.map((p) => p.trim())
.filter((p) => p.length > 0)
const { groups } = await ai.suggestChecklist({
repoId: repoId.value,
title: title.value.trim(),
content: content.length ? content : undefined,
})
aiSuggestions.value = groups
} catch {
aiError.value = '추천을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.'
} finally {
aiSuggesting.value = false
}
}
// 추천 항목을 체크리스트에 추가(중복 텍스트는 건너뜀)
function isSuggestionAdded(text: string): boolean {
return checklist.value.some((c) => c.text === text)
}
function addSuggestion(text: string) {
if (isSuggestionAdded(text)) return
checklist.value.push({ text, done: false })
}
function addSuggestionGroup(group: ChecklistSuggestionGroup) {
group.items.forEach((item) => addSuggestion(item))
}
function dismissSuggestions() {
aiSuggestions.value = null
aiError.value = null
}
// --- 제출 ---
const submitting = ref(false)
const canSubmit = computed(
@@ -235,6 +284,28 @@ function goBack() {
>
취소
</button>
<button
class="btn ai"
type="button"
:disabled="!canRequestAi"
title="저장소·업무 내용을 분석해 할 일을 추천합니다"
@click="requestAiSuggestions"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" />
<path d="M18 16l.7 2.3L21 19l-2.3.7L18 22l-.7-2.3L15 19l2.3-.7Z" />
</svg>
{{ aiSuggesting ? '분석 중…' : 'AI 추천' }}
</button>
<button
class="btn primary"
type="button"
@@ -424,6 +495,83 @@ function goBack() {
@keydown.enter="addItem"
>
</div>
<!-- AI 추천 -->
<div
v-if="aiSuggesting || aiSuggestions || aiError"
class="ai-suggest"
>
<div class="ai-suggest-head">
<span class="ai-suggest-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" />
</svg>
AI 추천
</span>
<button
v-if="aiSuggestions || aiError"
class="ai-suggest-close"
type="button"
aria-label="닫기"
@click="dismissSuggestions"
>
×
</button>
</div>
<div
v-if="aiSuggesting"
class="ai-suggest-msg"
>
저장소와 업무 내용을 분석하고 있어요
</div>
<div
v-else-if="aiError"
class="ai-suggest-msg err"
>
{{ aiError }}
</div>
<template v-else-if="aiSuggestions">
<div
v-for="group in aiSuggestions"
:key="group.category"
class="ai-group"
>
<div class="ai-group-head">
<span class="ai-group-cat">{{ group.category }}</span>
<button
class="ai-group-all"
type="button"
@click="addSuggestionGroup(group)"
>
전체 추가
</button>
</div>
<button
v-for="(item, i) in group.items"
:key="i"
class="ai-sg-item"
:class="{ added: isSuggestionAdded(item) }"
type="button"
:disabled="isSuggestionAdded(item)"
@click="addSuggestion(item)"
>
<span class="ai-sg-ico">{{ isSuggestionAdded(item) ? '✓' : '+' }}</span>
<span class="ai-sg-txt">{{ item }}</span>
</button>
</div>
<div class="ai-suggest-foot">
필요한 항목만 골라 추가하세요. (전부 추가할 필요는 없어요)
</div>
</template>
</div>
</div>
</section>
@@ -586,6 +734,25 @@ function goBack() {
.pagehead .btn {
height: 2.125rem;
}
.btn.ai {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--accent);
background: var(--accent-weak);
border: 1px solid var(--accent-border);
}
.btn.ai:hover:not(:disabled) {
background: #e7e4fb;
}
.btn.ai:disabled {
opacity: 0.55;
cursor: default;
}
.btn.ai svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 레이아웃 */
.layout {
@@ -839,6 +1006,125 @@ function goBack() {
color: var(--text-3);
}
/* AI 추천 할 일 */
.ai-suggest {
margin-top: 0.875rem;
border: 1px solid var(--accent-border);
background: #faf9ff;
border-radius: 0.625rem;
padding: 0.75rem 0.875rem;
}
.ai-suggest-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.ai-suggest-title {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--accent);
}
.ai-suggest-title svg {
width: 0.875rem;
height: 0.875rem;
}
.ai-suggest-close {
border: none;
background: transparent;
color: var(--text-3);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
padding: 0 0.25rem;
}
.ai-suggest-close:hover {
color: var(--text);
}
.ai-suggest-msg {
font-size: 0.8125rem;
color: var(--text-2);
padding: 0.375rem 0;
}
.ai-suggest-msg.err {
color: var(--red);
}
.ai-group {
margin-top: 0.625rem;
}
.ai-group:first-of-type {
margin-top: 0;
}
.ai-group-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3125rem;
}
.ai-group-cat {
font-size: 0.75rem;
font-weight: 700;
color: var(--text);
}
.ai-group-all {
border: none;
background: transparent;
color: var(--accent);
font-family: inherit;
font-size: 0.7187rem;
font-weight: 600;
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.ai-group-all:hover {
background: var(--accent-weak);
}
.ai-sg-item {
display: flex;
align-items: flex-start;
gap: 0.4375rem;
width: 100%;
text-align: left;
border: 1px solid var(--border);
background: #fff;
border-radius: 0.4375rem;
padding: 0.4375rem 0.5625rem;
margin-bottom: 0.3125rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--text);
cursor: pointer;
}
.ai-sg-item:hover:not(:disabled) {
border-color: var(--accent-border);
background: var(--accent-weak);
}
.ai-sg-item.added {
cursor: default;
color: var(--text-3);
background: #f4f5f7;
}
.ai-sg-ico {
font-weight: 700;
color: var(--accent);
line-height: 1.45;
}
.ai-sg-item.added .ai-sg-ico {
color: var(--green);
}
.ai-sg-txt {
flex: 1;
}
.ai-suggest-foot {
margin-top: 0.5rem;
font-size: 0.7187rem;
color: var(--text-3);
}
/* 사이드바 */
.side {
display: flex;