feat: add Prompt template library, agent_call inter-agent tool, and RAG memory

- New PromptTemplatePicker component for browsing 13 preset prompt templates
- AgentConfig.vue: "Load from library" button for system prompt
- Agents.vue: "Create from Prompt template" entry with agent node + RAG memory
- seed_prompt_templates.py: 13 preset templates (客服/研发/教育/内容/分析/创意/健康医疗)
- agent_call tool: agents can delegate tasks to other agents (19th builtin tool)
- Created 全能助手 (general orchestrator) and 家庭医生助手 agents
- Switch template-created agents from type:llm to type:agent for full ReAct + RAG

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-03 21:57:30 +08:00
parent 1c83b6284f
commit de415ca310
8 changed files with 1280 additions and 7 deletions

View File

@@ -0,0 +1,323 @@
<template>
<div class="prompt-template-picker">
<!-- 搜索与筛选 -->
<div class="picker-toolbar">
<el-input
v-model="searchText"
placeholder="搜索模板名称或描述..."
clearable
style="flex: 1"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="categoryFilter"
placeholder="分类筛选"
clearable
style="width: 160px; margin-left: 10px"
@change="handleSearch"
>
<el-option label="客服" value="customer_service" />
<el-option label="研发" value="dev" />
<el-option label="教育" value="education" />
<el-option label="内容" value="content" />
<el-option label="分析" value="analysis" />
<el-option label="创意" value="creative" />
<el-option label="健康医疗" value="healthcare" />
</el-select>
</div>
<!-- 模板列表 -->
<div v-loading="loading" class="template-list">
<div v-if="filteredTemplates.length === 0 && !loading" class="empty-state">
<el-empty description="暂无匹配的模板" />
</div>
<div
v-for="tpl in filteredTemplates"
:key="tpl.id"
class="template-card"
:class="{ selected: selectedId === tpl.id }"
@click="selectTemplate(tpl)"
>
<div class="template-header">
<span class="template-name">{{ tpl.name }}</span>
<el-tag
v-if="tpl.is_featured"
size="small"
type="warning"
effect="dark"
>
推荐
</el-tag>
</div>
<div class="template-desc">{{ tpl.description }}</div>
<div class="template-meta">
<el-tag size="small" type="info">{{ categoryLabel(tpl.category) }}</el-tag>
<el-tag
v-for="tag in (tpl.tags || [])"
:key="tag"
size="small"
class="tag-item"
>
{{ tag }}
</el-tag>
<span class="use-count" v-if="tpl.use_count">
<el-icon><CaretTop /></el-icon> {{ tpl.use_count }} 次使用
</span>
</div>
<!-- 选中后展开预览 -->
<div v-if="selectedId === tpl.id" class="template-preview">
<div class="preview-label">提示词预览</div>
<pre class="preview-content">{{ tpl.prompt }}</pre>
</div>
<div class="template-actions" v-if="selectedId === tpl.id">
<el-button type="primary" size="small" @click.stop="handleUse(tpl)">
使用此模板
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="picker-pagination" v-if="total > pageSize">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
small
@current-change="loadTemplates"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Search, CaretTop } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
interface PromptTemplate {
id: string
name: string
description: string
category: string
tags: string[]
prompt: string
is_featured: boolean
is_public: boolean
use_count?: number
model?: string
provider?: string
temperature?: string
variables?: Record<string, unknown>[]
}
defineProps({
pickMode: { type: Boolean, default: false },
})
const emit = defineEmits<{
use: [tpl: PromptTemplate]
select: [tpl: PromptTemplate]
}>()
const templates = ref<PromptTemplate[]>([])
const loading = ref(false)
const searchText = ref('')
const categoryFilter = ref('')
const selectedId = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const categoryLabel = (cat: string): string => {
const map: Record<string, string> = {
customer_service: '客服',
dev: '研发',
education: '教育',
content: '内容',
analysis: '分析',
creative: '创意',
healthcare: '健康医疗',
}
return map[cat] || cat
}
const loadTemplates = async () => {
loading.value = true
try {
const params = new URLSearchParams()
params.set('is_public', 'true')
params.set('limit', String(pageSize.value))
params.set('offset', String((currentPage.value - 1) * pageSize.value))
if (searchText.value) params.set('search', searchText.value)
if (categoryFilter.value) params.set('category', categoryFilter.value)
const token = localStorage.getItem('token')
const resp = await fetch(`/api/v1/node-templates?${params.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
templates.value = (data.items || data.data || data || []) as PromptTemplate[]
total.value = data.total || templates.value.length
} catch (e) {
console.error('加载 Prompt 模板失败:', e)
ElMessage.error('加载模板失败')
} finally {
loading.value = false
}
}
const filteredTemplates = computed(() => templates.value)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadTemplates()
}, 300)
}
const selectTemplate = (tpl: PromptTemplate) => {
selectedId.value = tpl.id
emit('select', tpl)
}
const handleUse = (tpl: PromptTemplate) => {
fetch(`/api/v1/node-templates/${tpl.id}/use`, { method: 'POST' }).catch(() => {})
emit('use', tpl)
selectedId.value = null
}
// ── 暴露方法 ──
defineExpose({ loadTemplates })
onMounted(() => {
loadTemplates()
})
</script>
<style scoped>
.prompt-template-picker {
display: flex;
flex-direction: column;
gap: 12px;
}
.picker-toolbar {
display: flex;
align-items: center;
}
.template-list {
max-height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
padding: 40px 0;
}
.template-card {
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
}
.template-card:hover {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.template-card.selected {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 0 0 1px var(--el-color-primary);
}
.template-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.template-name {
font-weight: 600;
font-size: 15px;
}
.template-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
line-height: 1.4;
}
.template-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tag-item {
margin: 0;
}
.use-count {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
.template-preview {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--el-border-color);
}
.preview-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.preview-content {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
background: var(--el-fill-color);
padding: 10px;
border-radius: 4px;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.template-actions {
margin-top: 10px;
text-align: right;
}
.picker-pagination {
display: flex;
justify-content: center;
}
</style>

View File

@@ -14,7 +14,8 @@ export const BUILTIN_SKILL_OPTIONS: { name: string; label: string }[] = [
{ name: 'system_info', label: '系统信息' },
{ name: 'json_process', label: 'JSON 处理' },
{ name: 'database_query', label: '数据库查询' },
{ name: 'adb_log', label: 'ADB 日志' }
{ name: 'adb_log', label: 'ADB 日志' },
{ name: 'agent_call', label: '调用 Agent' },
]
export const BUILTIN_SKILL_LABELS: Record<string, string> = Object.fromEntries(

View File

@@ -18,7 +18,16 @@
<el-form label-position="top" class="config-form">
<!-- System Prompt -->
<el-form-item label="系统提示词 (System Prompt)">
<el-form-item>
<template #label>
<div class="label-with-action">
<span>系统提示词 (System Prompt)</span>
<el-button size="small" text type="primary" @click="openTemplateDialog">
<el-icon><Collection /></el-icon>
Prompt 模板库选择
</el-button>
</div>
</template>
<el-input
v-model="form.system_prompt"
type="textarea"
@@ -143,6 +152,19 @@
</el-form-item>
</el-form>
</el-card>
<!-- Prompt 模板选择对话框 -->
<el-dialog
v-model="templateDialogVisible"
title="Prompt 模板库"
width="680px"
destroy-on-close
>
<PromptTemplatePicker
ref="templatePickerRef"
@use="onTemplateUse"
/>
</el-dialog>
</div>
</MainLayout>
</template>
@@ -151,8 +173,9 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ArrowLeft, Collection } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import PromptTemplatePicker from '@/components/PromptTemplatePicker.vue'
import { useAgentStore } from '@/stores/agent'
import { useModelConfigStore } from '@/stores/modelConfig'
import { BUILTIN_SKILL_OPTIONS } from '@/utils/agentSkills'
@@ -239,6 +262,24 @@ function findAgentNode(nodes: WorkflowNode[]): WorkflowNode | undefined {
return nodes.find((n) => n.type === 'agent' || n.type === 'llm')
}
// ── Prompt 模板对话框 ──
const templateDialogVisible = ref(false)
const templatePickerRef = ref()
function openTemplateDialog() {
templateDialogVisible.value = true
}
function onTemplateUse(tpl: any) {
form.value.system_prompt = tpl.prompt || ''
// 可选:同步填充模型配置
if (tpl.model) form.value.model = tpl.model
if (tpl.provider) form.value.provider = tpl.provider
if (tpl.temperature) form.value.temperature = parseFloat(tpl.temperature)
templateDialogVisible.value = false
ElMessage.success(`已加载模板「${tpl.name}`)
}
function goBack() {
router.push('/agents')
}
@@ -320,6 +361,13 @@ async function handleSave() {
margin-top: 8px;
}
.label-with-action {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);

View File

@@ -13,6 +13,10 @@
<el-button @click="openTemplateDialog">
从场景模板创建
</el-button>
<el-button @click="openPromptCreateDialog">
<el-icon><Collection /></el-icon>
Prompt 模板创建
</el-button>
<el-button type="primary" @click="handleCreate" style="margin-left: 10px">
<el-icon><Plus /></el-icon>
创建Agent
@@ -317,6 +321,49 @@
</template>
</el-dialog>
<!-- Prompt 模板创建 -->
<el-dialog
v-model="promptCreateDialogVisible"
title="从 Prompt 模板创建 Agent"
width="700px"
destroy-on-close
@close="resetPromptCreateForm"
>
<PromptTemplatePicker
ref="promptCreatePickerRef"
@use="onPromptCreateUse"
/>
<template #footer>
<span class="dialog-tip">请先在上方选择 Prompt 模板然后填写 Agent 信息</span>
</template>
</el-dialog>
<!-- 填写 Agent 信息Prompt 模板已有选中的 -->
<el-dialog
v-model="promptCreateInfoVisible"
title="完善 Agent 信息"
width="500px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="Prompt 模板">
<el-tag>{{ promptCreateSelected?.name }}</el-tag>
</el-form-item>
<el-form-item label="名称" required>
<el-input v-model="promptCreateForm.name" placeholder="新 Agent 名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="promptCreateForm.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="promptCreateInfoVisible = false">取消</el-button>
<el-button type="primary" :loading="promptCreateSubmitting" @click="submitPromptCreate">
创建 Agent
</el-button>
</template>
</el-dialog>
<!-- 能力 / 技能配置 -->
<el-dialog
v-model="skillDialogVisible"
@@ -384,6 +431,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import MainLayout from '@/components/MainLayout.vue'
import PromptTemplatePicker from '@/components/PromptTemplatePicker.vue'
import {
Plus,
Search,
@@ -399,7 +447,8 @@ import {
UploadFilled,
ChatDotRound,
Tools,
Operation
Operation,
Collection
} from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import type { Agent } from '@/stores/agent'
@@ -489,6 +538,89 @@ async function submitTemplateCreate() {
}
}
// ── 从 Prompt 模板创建 ──
const promptCreateDialogVisible = ref(false)
const promptCreateInfoVisible = ref(false)
const promptCreateSubmitting = ref(false)
const promptCreateSelected = ref<any>(null)
const promptCreatePickerRef = ref()
const promptCreateForm = ref({
name: '',
description: '',
})
function openPromptCreateDialog() {
promptCreateDialogVisible.value = true
}
function resetPromptCreateForm() {
promptCreateSelected.value = null
promptCreateForm.value = { name: '', description: '' }
}
function onPromptCreateUse(tpl: any) {
promptCreateSelected.value = tpl
promptCreateForm.value.name = tpl.name || ''
promptCreateForm.value.description = tpl.description || ''
promptCreateDialogVisible.value = false
promptCreateInfoVisible.value = true
}
async function submitPromptCreate() {
const name = promptCreateForm.value.name.trim()
if (!name) {
ElMessage.warning('请输入名称')
return
}
if (!promptCreateSelected.value) {
ElMessage.warning('请先选择 Prompt 模板')
return
}
const tpl = promptCreateSelected.value
const workflowConfig = {
nodes: [
{ id: 'start-1', type: 'start', position: { x: 80, y: 120 }, data: {} },
{
id: 'agent-1',
type: 'agent',
position: { x: 320, y: 120 },
data: {
label: name,
system_prompt: tpl.prompt,
model: tpl.model || 'deepseek-v4-flash',
provider: tpl.provider || 'deepseek',
temperature: parseFloat(tpl.temperature || '0.7'),
max_iterations: 10,
tools: [],
memory: true,
},
},
{ id: 'end-1', type: 'end', position: { x: 560, y: 120 }, data: {} },
],
edges: [
{ id: 'e_start_agent', source: 'start-1', target: 'agent-1', sourceHandle: 'right', targetHandle: 'left' },
{ id: 'e_agent_end', source: 'agent-1', target: 'end-1', sourceHandle: 'right', targetHandle: 'left' },
],
}
promptCreateSubmitting.value = true
try {
await agentStore.createAgent({
name,
description: promptCreateForm.value.description?.trim() || undefined,
workflow_config: workflowConfig,
})
ElMessage.success(`已从 Prompt 模板创建 Agent「${name}`)
promptCreateInfoVisible.value = false
await loadAgents()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建失败')
} finally {
promptCreateSubmitting.value = false
}
}
// 搜索和筛选
const searchText = ref('')
const statusFilter = ref('')
@@ -1018,4 +1150,9 @@ onMounted(() => {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>