Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/packages/kbx-ui/src/components/KbxAiAssistant.vue
T

97 lines
5.7 KiB
Vue

<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxAiAnswer, KbxAiAnswerAction, KbxAiScreenContext } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
const props = defineProps<{
context: KbxAiScreenContext
answer?: KbxAiAnswer | null
loading?: boolean
error?: string
currentScreenLabel?: string
quickQuestions?: string[]
can?: (permission:string)=>boolean
}>()
const emit = defineEmits<{
ask: [question: string]
action: [actionId: string]
openProposal: []
}>()
const permissionHost=inject(KbxPermissionHostKey,null)
const question = ref('')
const lastSubmitted = ref('')
const canAsk = computed(() => question.value.trim().length > 1 && !props.loading)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??false)}
function actionAllowed(action:KbxAiAnswerAction){
const capabilityAllowed=!action.requiredCapability||props.context.allowedCapabilities.includes(action.requiredCapability)
const permissionAllowed=!action.requiredPermission||canPermission(action.requiredPermission)
return capabilityAllowed&&permissionAllowed
}
const actions=computed(()=>props.answer?.actions?.filter(actionAllowed)??[])
const proposalAllowed=computed(()=>{
const proposal=props.answer?.proposal
if(!proposal)return false
if(!props.context.allowedCapabilities.includes(proposal.capability))return false
if(proposal.requiredPermission&&!canPermission(proposal.requiredPermission))return false
return true
})
const proposalBlocked=computed(()=>Boolean(props.answer?.proposal)&&!proposalAllowed.value)
function ask(value = question.value) {
const normalized = value.trim()
if (normalized.length < 2 || props.loading) return
lastSubmitted.value=normalized
emit('ask', normalized)
question.value = ''
}
function retry(){if(lastSubmitted.value&&!props.loading)emit('ask',lastSubmitted.value)}
</script>
<template>
<section class="kbx-ai" aria-labelledby="kbx-ai-title" data-kbx-component="ai-assistant" :aria-busy="loading || undefined">
<header>
<h2 id="kbx-ai-title">AI 도우미</h2>
<small>{{ currentScreenLabel || '현재 업무' }}</small>
</header>
<div v-if="quickQuestions?.length" class="kbx-ai__quick" aria-label="빠른 질문">
<KbxButton v-for="item in quickQuestions" :key="item" :label="item" variant="secondary" :disabled="loading" @click="ask(item)" />
</div>
<div v-if="error" class="kbx-ai__error" role="alert">
<strong>AI 답변을 가져오지 못했습니다.</strong><span>{{error}}</span><KbxButton v-if="lastSubmitted" label="다시 질문" variant="secondary" :loading="loading" @click="retry" />
</div>
<article v-if="answer" class="kbx-ai__answer" aria-live="polite">
<p>{{ answer.answer }}</p>
<div v-if="answer.evidence?.length" class="kbx-ai__evidence">
<strong>근거</strong>
<ul>
<li v-for="evidence in answer.evidence" :key="`${evidence.sourceType}:${evidence.reference ?? evidence.label}`">
{{ evidence.label }} <small>({{ evidence.sourceType }})</small>
</li>
</ul>
</div>
<div v-if="actions.length" class="kbx-ai__actions">
<KbxButton v-for="action in actions" :key="action.id" :label="action.label" variant="secondary" @click="emit('action', action.id)" />
</div>
<KbxButton v-if="answer.proposal && proposalAllowed" label="변경 제안 확인" variant="secondary" @click="emit('openProposal')" />
<p v-else-if="proposalBlocked" class="kbx-ai__guard" role="status">현재 사용자 권한 또는 AI 허용 범위를 벗어난 변경 제안은 실행할 없습니다.</p>
</article>
<form class="kbx-ai__ask" @submit.prevent="ask()">
<label for="kbx-ai-question">질문</label>
<textarea id="kbx-ai-question" v-model="question" rows="3" maxlength="1000" placeholder="현재 업무에 대해 질문하세요." />
<KbxButton label="질문" variant="primary" :disabled="!canAsk" :loading="loading" @click="ask()" />
</form>
<p class="kbx-ai__guard">AI 답변은 업무 근거를 설명하거나 제안합니다. 실제 변경은 권한·대상 식별·서버 업무규칙 검증을 거쳐 별도 실행합니다.</p>
</section>
</template>
<style scoped>
.kbx-ai{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-4);font-size:var(--kbx-font-md)}header{display:flex;justify-content:space-between;align-items:baseline;gap:var(--kbx-space-2)}h2{margin:0;font-size:var(--kbx-font-xl)}header small{color:var(--kbx-color-text-muted)}.kbx-ai__quick,.kbx-ai__actions{display:flex;gap:var(--kbx-space-2);flex-wrap:wrap}.kbx-ai__answer{display:grid;gap:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);padding:var(--kbx-space-3);background:var(--kbx-color-surface-muted)}.kbx-ai__answer p{margin:0;white-space:pre-wrap;line-height:1.55}.kbx-ai__evidence{display:grid;gap:var(--kbx-space-1)}.kbx-ai__evidence ul{margin:0;padding-left:var(--kbx-space-5);color:var(--kbx-color-text-muted)}.kbx-ai__error{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-ai__error span{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-ai__ask{display:grid;gap:var(--kbx-space-2)}textarea{resize:vertical;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);font:inherit}.kbx-ai__ask :deep(.p-button){justify-self:end}.kbx-ai__guard{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);line-height:1.45}
</style>