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:
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
|
||||
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
|
||||
|
||||
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
|
||||
@ApiTags('AI')
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiSuggestController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
|
||||
@Post('suggest-checklist')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '할 일(체크리스트) 추천 — 저장소·업무 기반' })
|
||||
@ApiResponse({ status: 200, description: '카테고리별 추천 항목' })
|
||||
@ApiResponse({ status: 404, description: '저장소 없음 (RES_001)' })
|
||||
suggestChecklist(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: SuggestChecklistDto,
|
||||
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
|
||||
return this.aiService.suggestChecklist(user.id, {
|
||||
repoId: dto.repoId,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { RepoModule } from '../repo/repo.module';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiSuggestController } from './ai-suggest.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
@@ -15,7 +16,7 @@ import { AiMessage } from './entities/ai-message.entity';
|
||||
TaskModule,
|
||||
RepoModule,
|
||||
],
|
||||
controllers: [AiController],
|
||||
controllers: [AiController, AiSuggestController],
|
||||
providers: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface SendResult {
|
||||
reply: string;
|
||||
}
|
||||
|
||||
// 할 일 추천 — 카테고리별 항목
|
||||
export interface ChecklistSuggestionGroup {
|
||||
category: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_TOKENS = 1024;
|
||||
@@ -70,6 +76,21 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
done: '완료',
|
||||
};
|
||||
|
||||
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
|
||||
const SUGGEST_SYSTEM_PROMPT = [
|
||||
'당신은 소프트웨어/업무 기획 보조자입니다.',
|
||||
"주어진 저장소와 업무 정보를 바탕으로, 이 업무(기능)를 완성하기 위해 필요한 '할 일(체크리스트)' 항목을 제안합니다.",
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
|
||||
'- 카테고리는 "필수 기능", "추가 기능", "보안 기능" 을 기본으로 하되 업무 성격에 맞게 가감하세요.',
|
||||
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
|
||||
'- 업무·저장소 맥락에 구체적으로 맞추고 일반론은 피하세요. 카테고리당 3~6개가 적당합니다.',
|
||||
'',
|
||||
'[형식]',
|
||||
'{"groups":[{"category":"필수 기능","items":["...","..."]},{"category":"보안 기능","items":["..."]}]}',
|
||||
].join('\n');
|
||||
|
||||
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
|
||||
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
|
||||
@Injectable()
|
||||
@@ -163,6 +184,76 @@ export class AiService {
|
||||
return this.appendAndReply(conv, content, userId);
|
||||
}
|
||||
|
||||
// 할 일(체크리스트) 추천 — 저장소 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
|
||||
async suggestChecklist(
|
||||
userId: string,
|
||||
input: { repoId: string; title: string; content?: string[] },
|
||||
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
|
||||
this.ensureEnabled();
|
||||
// 저장소 정보(이름/설명) — 조직 모델상 인증 사용자면 열람 가능. 없으면 404.
|
||||
const repo = await this.repoService.findOne(input.repoId, userId);
|
||||
const content = (input.content ?? []).join('\n').trim() || '(없음)';
|
||||
const userMsg = [
|
||||
`[저장소] 표시 제목: ${repo.name}`,
|
||||
`설명: ${repo.desc ?? '(없음)'}`,
|
||||
'',
|
||||
`[업무] 제목: ${input.title}`,
|
||||
'내용:',
|
||||
content,
|
||||
].join('\n');
|
||||
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
);
|
||||
return { groups: this.parseSuggestions(raw) };
|
||||
}
|
||||
|
||||
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
|
||||
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
|
||||
let text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/, '')
|
||||
.trim();
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) text = text.slice(start, end + 1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw this.suggestFailed();
|
||||
}
|
||||
|
||||
const rawGroups = (parsed as { groups?: unknown }).groups;
|
||||
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
|
||||
const result: ChecklistSuggestionGroup[] = [];
|
||||
for (const g of groups.slice(0, 8)) {
|
||||
const obj = g as { category?: unknown; items?: unknown };
|
||||
const category =
|
||||
typeof obj.category === 'string' ? obj.category.trim() : '';
|
||||
if (!category || !Array.isArray(obj.items)) continue;
|
||||
const items = obj.items
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.slice(0, 15);
|
||||
if (items.length > 0) result.push({ category, items });
|
||||
}
|
||||
if (result.length === 0) throw this.suggestFailed();
|
||||
return result;
|
||||
}
|
||||
|
||||
private suggestFailed(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'추천을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// 대화 삭제(본인 소유만) — 메시지 CASCADE
|
||||
async deleteConversation(
|
||||
userId: string,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 할 일(체크리스트) 추천 요청 — 저장소·업무 정보를 바탕으로 AI 가 항목 제안
|
||||
export class SuggestChecklistDto {
|
||||
@ApiProperty({ description: '저장소 식별자(slugName)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '저장소가 필요합니다.' })
|
||||
repoId!: string;
|
||||
|
||||
@ApiProperty({ description: '업무 제목' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업무 내용(문단 배열)',
|
||||
required: false,
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(5000, { each: true })
|
||||
content?: string[];
|
||||
}
|
||||
@@ -345,11 +345,13 @@ Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가
|
||||
| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 |
|
||||
| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 |
|
||||
| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) |
|
||||
| POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 |
|
||||
|
||||
- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기).
|
||||
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지).
|
||||
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트).
|
||||
- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크).
|
||||
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
|
||||
|
||||
### 그 외(미착수)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ConversationDetail,
|
||||
ConversationSummary,
|
||||
SendResult,
|
||||
SuggestChecklistResult,
|
||||
} from '@/types/ai'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
@@ -43,11 +44,23 @@ export function useAi() {
|
||||
await api.delete(`/ai/conversations/${id}`)
|
||||
}
|
||||
|
||||
// 할 일(체크리스트) 추천 — 실패는 호출측에서 인라인 처리(silent)
|
||||
async function suggestChecklist(payload: {
|
||||
repoId: string
|
||||
title: string
|
||||
content?: string[]
|
||||
}): Promise<SuggestChecklistResult> {
|
||||
return (await api.post('/ai/suggest-checklist', payload, {
|
||||
silent: true,
|
||||
})) as unknown as SuggestChecklistResult
|
||||
}
|
||||
|
||||
return {
|
||||
listConversations,
|
||||
getConversation,
|
||||
startConversation,
|
||||
sendMessage,
|
||||
deleteConversation,
|
||||
suggestChecklist,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,3 +24,12 @@ export interface SendResult {
|
||||
title: string
|
||||
reply: string
|
||||
}
|
||||
|
||||
// 할 일(체크리스트) 추천 — 카테고리별 항목
|
||||
export interface ChecklistSuggestionGroup {
|
||||
category: string
|
||||
items: string[]
|
||||
}
|
||||
export interface SuggestChecklistResult {
|
||||
groups: ChecklistSuggestionGroup[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user