97 lines
5.7 KiB
Vue
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>
|