Files
replypier/frontend/src/components/workflow/nodes/LLMNodeConfig.vue
T

859 lines
21 KiB
Vue
Raw Normal View History

<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAgentEdit } from '@/composables/useAgentEdit'
import KnowledgeGrid from '@/components/agent/KnowledgeGrid.vue'
import { ExitCondition } from '@/types/workflow'
import type { UserGroup } from '@/types/user'
interface FormData {
system_prompt: string
temperature: number
exit_condition: string
auto_transfer_enabled: boolean
transfer_group_id: string
ask_for_rating: boolean
}
const props = defineProps<{
modelValue: FormData
validationErrors: Record<string, string>
userGroups: UserGroup[]
loadingGroups: boolean
agentId: string
organizationId: string
collapsedSections: Record<string, boolean>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: FormData): void
(e: 'validateField', field: string): void
(e: 'toggleSection', section: string): void
}>()
// Local reactive copy of the form data
const localFormData = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// AI generation state
const showAIPrompt = ref(false)
const aiPrompt = ref('')
// Initialize agent edit composable (using a mock agent object for AI generation)
const mockAgent = {
id: 'temp-agent',
name: 'Workflow Node Agent',
display_name: 'Workflow Node Agent',
description: 'Temporary agent for AI generation',
agent_type: 'general',
instructions: '',
transfer_to_human: false,
ask_for_rating: false,
organization_id: 'temp',
created_by: 'temp',
created_at: new Date(),
updated_at: new Date(),
is_active: true,
enable_rate_limiting: false,
overall_limit_per_ip: 0,
requests_per_sec: 0,
conversation_starters: [],
knowledge_base_ids: [],
canvas_data: {}
} as any
const { generateInstructions, isLoading: aiLoading, error: aiError } = useAgentEdit(mockAgent)
// Handle field updates
const updateField = (field: keyof FormData, value: any) => {
const updated = { ...localFormData.value, [field]: value }
emit('update:modelValue', updated)
emit('validateField', field)
}
// Handle AI generation for system prompt
const handleGenerateWithAI = async () => {
if (!aiPrompt.value.trim()) return
try {
const generatedInstructions = await generateInstructions(aiPrompt.value)
if (generatedInstructions.length > 0) {
// Join the generated instructions with newlines
updateField('system_prompt', generatedInstructions.join('\n'))
showAIPrompt.value = false
aiPrompt.value = ''
}
} catch (err) {
console.error('Failed to generate instructions:', err)
}
}
const validateFieldOnChange = (field: string) => {
emit('validateField', field)
}
const toggleSection = (section: string) => {
emit('toggleSection', section)
}
</script>
<template>
<div class="llm-node-config">
<div class="form-group">
<div class="instructions-header">
<label for="system-prompt">指令 *</label>
<button
class="ai-generate-button"
@click="showAIPrompt = true"
:disabled="aiLoading"
type="button"
>
<span class="ai-icon">✨</span>
AI 生成
</button>
</div>
<textarea
id="system-prompt"
:value="localFormData.system_prompt"
@input="updateField('system_prompt', ($event.target as HTMLInputElement).value)"
class="form-textarea"
:class="{ 'error': validationErrors.system_prompt }"
placeholder="输入给 AI 的系统提示词"
rows="4"
required
@blur="validateFieldOnChange('system_prompt')"
></textarea>
<div v-if="validationErrors.system_prompt" class="error-message">
{{ validationErrors.system_prompt }}
</div>
</div>
<div class="form-group">
<label for="temperature">温度</label>
<input
id="temperature"
:value="localFormData.temperature"
@input="updateField('temperature', parseFloat(($event.target as HTMLInputElement).value))"
type="number"
class="form-input"
:class="{ 'error': validationErrors.temperature }"
min="0"
max="2"
step="0.1"
placeholder="0.7"
@blur="validateFieldOnChange('temperature')"
/>
<div v-if="validationErrors.temperature" class="error-message">
{{ validationErrors.temperature }}
</div>
<p class="help-text">
控制回复的随机性。数值越低(0.1)越聚焦,数值越高(1.5)越有创造性。
</p>
</div>
<div class="form-group">
<label for="exit-condition">退出条件 *</label>
<select
id="exit-condition"
:value="localFormData.exit_condition"
@change="updateField('exit_condition', ($event.target as HTMLSelectElement).value)"
class="form-select"
:class="{ 'error': validationErrors.exit_condition }"
required
@blur="validateFieldOnChange('exit_condition')"
>
<option :value="ExitCondition.SINGLE_EXECUTION">单次执行</option>
<option :value="ExitCondition.CONTINUOUS_EXECUTION">持续执行</option>
</select>
<div v-if="validationErrors.exit_condition" class="error-message">
{{ validationErrors.exit_condition }}
</div>
<p class="help-text">
<template v-if="localFormData.exit_condition === ExitCondition.SINGLE_EXECUTION">
AI 回复一次后总是进入下一个节点(默认)。
</template>
<template v-else-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION">
停留在当前节点持续对话,直到请求结束会话。
</template>
</p>
</div>
<!-- Transfer to Human Setting - Only for Continuous Execution -->
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
<label class="checkbox-group">
<input
:checked="localFormData.auto_transfer_enabled"
@change="updateField('auto_transfer_enabled', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-label">启用自动转人工</span>
</label>
<p class="help-text">
启用后,AI 判断需要人工协助时可自动将会话转接给人工客服,且不会结束会话。
</p>
<!-- Group selection - Only when transfer is enabled -->
<div v-if="localFormData.auto_transfer_enabled" class="transfer-groups">
<label for="transfer-group">转接分组</label>
<div v-if="!loadingGroups">
<select
id="transfer-group"
:value="localFormData.transfer_group_id"
@change="updateField('transfer_group_id', ($event.target as HTMLSelectElement).value)"
class="form-select"
>
<option value="">选择分组</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
<p v-if="userGroups.length === 0" class="help-text no-groups-message">
暂无可用分组。
<router-link to="/human-agents" class="create-group-link">
创建分组
</router-link>
</p>
<p v-else class="help-text">
选择负责处理转接会话的分组。
</p>
</div>
<div v-else class="loading-groups">
正在加载分组……
</div>
</div>
</div>
<!-- Request Rating Setting - Only for Continuous Execution -->
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
<label class="checkbox-group">
<input
:checked="localFormData.ask_for_rating"
@change="updateField('ask_for_rating', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-label">会话结束后邀请评分</span>
</label>
<p class="help-text">
启用后,会话结束时将邀请客户对体验进行评分。
</p>
</div>
<!-- Knowledge Section -->
<div class="form-group">
<div class="knowledge-section">
<div class="section-header knowledge-header" @click="toggleSection('knowledge')">
<div class="section-title">
<svg class="section-icon" :class="{ 'rotated': collapsedSections.knowledge }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
<span>知识来源</span>
</div>
</div>
<div class="section-content knowledge-content" :class="{ 'collapsed': collapsedSections.knowledge }">
<div class="knowledge-wrapper">
<KnowledgeGrid
v-if="!collapsedSections.knowledge"
:agent-id="agentId"
:organization-id="organizationId"
/>
</div>
</div>
</div>
</div>
<!-- AI Prompt Modal -->
<div v-if="showAIPrompt" class="ai-prompt-modal">
<div class="ai-prompt-content">
<h5>用 AI 生成指令</h5>
<textarea
v-model="aiPrompt"
placeholder="描述你希望这个 AI 节点做什么。例如:“为一个帮助查询订单的客服助手生成指令”"
rows="4"
class="ai-prompt-textarea"
></textarea>
<div v-if="aiError" class="error-message">{{ aiError }}</div>
<div class="ai-prompt-actions">
<button
class="cancel-ai-button"
@click="showAIPrompt = false"
:disabled="aiLoading"
type="button"
>
取消
</button>
<button
class="generate-ai-button"
@click="handleGenerateWithAI"
:disabled="aiLoading || !aiPrompt.trim()"
type="button"
>
{{ aiLoading ? '生成中……' : '生成' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Import shared form styles */
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-input.error,
.form-textarea.error,
.form-select.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-input.error:focus,
.form-textarea.error:focus,
.form-select.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.checkbox-group {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0;
}
.checkbox-group input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
.checkbox-label {
font-size: 0.85rem;
color: var(--text-color);
line-height: 1.4;
}
.help-text {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.3;
}
.form-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
/* Instructions Header Styles */
.instructions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.ai-generate-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.ai-generate-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.ai-generate-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-icon {
font-size: 0.8rem;
}
/* AI Prompt Modal Styles */
.ai-prompt-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.ai-prompt-content {
background: var(--background-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 90%;
max-width: 500px;
box-shadow: var(--shadow-xl);
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.ai-prompt-content h5 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.ai-prompt-textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-soft);
color: var(--text-color);
font-size: 0.85rem;
resize: vertical;
min-height: 100px;
box-sizing: border-box;
}
.ai-prompt-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
.cancel-ai-button,
.generate-ai-button {
padding: 8px 16px;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
min-width: 80px;
white-space: nowrap;
}
.cancel-ai-button {
background: var(--background-muted);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.cancel-ai-button:hover {
background: var(--background-alt);
color: var(--text-color);
}
.generate-ai-button {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.generate-ai-button:hover {
background: var(--primary-dark);
}
.generate-ai-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Transfer groups styling */
.transfer-groups {
margin-top: var(--space-md);
padding: var(--space-md);
background: var(--background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.transfer-groups label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.no-groups-message {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 4px;
}
.create-group-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.create-group-link:hover {
text-decoration: underline;
}
.loading-groups {
text-align: center;
padding: var(--space-sm);
color: var(--text-muted);
font-size: 0.8rem;
}
/* Knowledge Section Styles */
.knowledge-section {
margin-top: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
overflow: hidden;
}
.knowledge-header .section-header {
background: var(--background-muted);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
transition: all 0.2s ease;
}
.knowledge-header .section-header:hover {
background: var(--background-alt);
}
.section-title {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
}
.section-icon {
width: 16px;
height: 16px;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.section-icon.rotated {
transform: rotate(-90deg);
}
.knowledge-content {
padding: 0;
max-height: none;
overflow: visible;
transition: all 0.3s ease;
}
.knowledge-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
overflow: hidden;
}
.knowledge-wrapper {
position: relative;
width: 100%;
height: 100%;
}
/* Override KnowledgeGrid styles within LLMNodeConfig */
.knowledge-content :deep(.knowledge-grid-container) {
padding: var(--space-sm);
background: transparent;
border-top: none;
}
.knowledge-content :deep(.knowledge-header) {
margin-bottom: var(--space-sm);
flex-direction: column;
gap: var(--space-xs);
align-items: stretch;
}
.knowledge-content :deep(.header-left) {
flex-direction: column;
gap: var(--space-xs);
align-items: stretch;
}
.knowledge-content :deep(.header-left h3) {
font-size: 1rem;
margin-bottom: 0;
}
.knowledge-content :deep(.header-actions) {
display: flex;
gap: var(--space-xs);
justify-content: center;
}
.knowledge-content :deep(.action-button) {
font-size: 0.7rem;
padding: 4px 8px;
flex: 1;
text-align: center;
white-space: nowrap;
}
.knowledge-content :deep(.knowledge-grid) {
font-size: 0.8rem;
}
.knowledge-content :deep(.knowledge-grid-header),
.knowledge-content :deep(.knowledge-grid-row) {
grid-template-columns: 1fr 60px;
min-height: auto;
}
.knowledge-content :deep(.header-cell),
.knowledge-content :deep(.grid-cell) {
padding: var(--space-xs) var(--space-sm);
font-size: 0.75rem;
}
.knowledge-content :deep(.header-cell:nth-child(2)),
.knowledge-content :deep(.header-cell:nth-child(3)),
.knowledge-content :deep(.header-cell:nth-child(4)),
.knowledge-content :deep(.grid-cell:nth-child(2)),
.knowledge-content :deep(.grid-cell:nth-child(3)),
.knowledge-content :deep(.grid-cell:nth-child(4)) {
display: none;
}
.knowledge-content :deep(.actions-cell) {
width: 60px;
text-align: center;
}
.knowledge-content :deep(.delete-button) {
padding: 2px;
}
.knowledge-content :deep(.delete-icon) {
width: 16px;
height: 16px;
}
.knowledge-content :deep(.knowledge-empty) {
padding: var(--space-md);
text-align: center;
}
.knowledge-content :deep(.warning-message) {
font-size: 0.8rem;
margin-bottom: var(--space-xs);
}
.knowledge-content :deep(.warning-description) {
font-size: 0.75rem;
line-height: 1.4;
}
.knowledge-content :deep(.modal-content) {
max-width: 30vw;
max-height: 75vh;
width: 65%;
}
/* Ensure knowledge modals have proper z-index */
.knowledge-content :deep(.modal-overlay) {
z-index: 1001 !important;
position: fixed !important;
isolation: isolate;
background: rgba(0, 0, 0, 0.4) !important;
}
.knowledge-content :deep(.modal-overlay),
.knowledge-content :deep(.modal-content) {
pointer-events: auto;
}
.knowledge-content :deep(.modal-overlay) {
backdrop-filter: blur(4px);
}
.knowledge-content :deep(.pagination) {
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.knowledge-content :deep(.pagination-button) {
width: 100%;
font-size: 0.7rem;
padding: var(--space-xs);
}
.knowledge-content :deep(.page-info) {
font-size: 0.7rem;
text-align: center;
}
/* Link modal overrides */
.knowledge-content :deep(.link-modal) {
max-width: 65vw;
width: 60%;
}
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
grid-template-columns: 2fr 0fr 100px;
align-items: center;
}
.knowledge-content :deep(.org-knowledge-grid .type-cell) {
display: none;
}
.knowledge-content :deep(.source-cell) {
padding-left: var(--space-sm);
white-space: normal;
word-break: break-word;
font-size: 0.75rem;
}
.knowledge-content :deep(.action-cell) {
padding-right: var(--space-sm);
text-align: right;
width: 100px;
}
.knowledge-content :deep(.link-button),
.knowledge-content :deep(.unlink-button) {
min-width: 70px;
padding: 4px 8px;
font-size: 0.7rem;
display: inline-block;
}
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
display: block !important;
visibility: visible !important;
}
.knowledge-content :deep(.org-knowledge-grid) .link-button,
.knowledge-content :deep(.org-knowledge-grid) .unlink-button {
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
}
@media (max-width: 768px) {
.knowledge-content :deep(.knowledge-grid-container) {
padding: 4px;
}
.knowledge-content :deep(.header-actions) {
flex-direction: column;
gap: 4px;
}
.knowledge-content :deep(.action-button) {
font-size: 0.65rem;
padding: 3px 6px;
}
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
grid-template-columns: 2fr 0fr 100px !important;
}
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
display: block !important;
grid-column: 3 !important;
}
}
</style>