Files
ruoyi-vue/src/views/system/social-media/prompt-config.vue
2026-04-05 21:37:47 +08:00

780 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="prompt-config-container">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span class="card-title">
<i class="el-icon-setting"></i>
DS提示词模板配置
</span>
<el-button
style="float: right; padding: 3px 0"
type="text"
@click="showHelp = !showHelp">
{{ showHelp ? '隐藏帮助' : '显示帮助' }}
</el-button>
</div>
<!-- 帮助说明 -->
<el-collapse-transition>
<div v-show="showHelp" class="help-section">
<el-alert
title="使用说明"
type="info"
:closable="false"
show-icon>
<div slot="default">
<p><strong>功能说明</strong></p>
<ul>
<li>配置 DS 侧提示词与闲鱼手动文案模板存储在 Redis修改后立即生效</li>
<li>DS 相关模板支持占位符%s 用于替换实际内容</li>
<li>闲鱼模板为纯文本或一条 Java 正则标题清洗无占位符</li>
<li>未在 Redis 中保存过时文本框会回显当前内置默认全文便于对照修改保存后即写入 Redis</li>
<li>删除模板将清空 Redis 并再次回显内置默认</li>
</ul>
<p><strong>模板类型</strong></p>
<ul>
<li><strong>关键词提取模板</strong>用于提取商品关键词占位符%s = 商品名称</li>
<li><strong>小红书文案模板</strong>用于生成小红书风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
<li><strong>抖音文案模板</strong>用于生成抖音风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
<li><strong>通用文案模板</strong>用于生成通用风格文案占位符%s = 商品名称%s = 价格信息%s = 关键词</li>
<li><strong>闲鱼·正文基础</strong>一键代下教你下单两版共用的正文说明在标题/型号行之后</li>
<li><strong>闲鱼·教你下单尾部</strong>接在更新日期后的附加说明</li>
<li><strong>闲鱼·标题清洗正则</strong>从标题型号备注中删除匹配片段须为合法 Java 正则</li>
</ul>
</div>
</el-alert>
</div>
</el-collapse-transition>
<!-- 模板列表 -->
<div class="template-list">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane
v-for="(template, key) in templates"
:key="key"
:label="getTemplateLabel(key)"
:name="key">
<div class="template-editor">
<div class="template-info">
<el-alert
:title="template.description"
type="info"
:closable="false"
show-icon
style="margin-bottom: 15px;">
</el-alert>
<div class="template-status">
<el-tag :type="template.isDefault ? 'info' : 'success'" size="small">
{{ template.isDefault ? '内置默认(未写入 Redis可编辑后保存' : '已自定义(已写入 Redis' }}
</el-tag>
<el-button
v-if="!template.isDefault"
type="text"
size="small"
icon="el-icon-refresh-left"
@click="handleResetTemplate(key)"
style="margin-left: 10px;">
恢复默认
</el-button>
</div>
</div>
<el-input
v-model="template.template"
type="textarea"
:rows="12"
placeholder="请输入提示词模板..."
class="template-textarea">
</el-input>
<div class="template-actions">
<el-button
type="primary"
icon="el-icon-check"
@click="handleSaveTemplate(key)"
:loading="saving[key]">
保存模板
</el-button>
<el-button
icon="el-icon-refresh"
@click="handleLoadTemplate(key)">
重新加载
</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-card>
<el-card class="box-card llm-card" style="margin-top: 20px;">
<div slot="header" class="clearfix">
<span class="card-title">
<i class="el-icon-connection"></i>
大模型接入配置多套 · 激活一套生效
</span>
<div style="float: right;">
<el-button type="primary" size="small" icon="el-icon-plus" @click="openLlmDialog(false)">新增配置</el-button>
<el-button size="small" icon="el-icon-caret-right" @click="openLlmTest(null)">测试当前激活</el-button>
<el-button size="small" icon="el-icon-refresh" @click="loadLlmList">刷新</el-button>
</div>
</div>
<el-alert
v-if="llmSummary && llmSummary.redisAvailable === false"
title="当前未连接 Redis无法保存Jarvis 将始终使用 application.yml 中的默认 Ollama。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 16px;" />
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;">
<div slot="title">
<p style="margin: 0 0 8px 0;"><strong>说明</strong>可保存多套如本地 Ollama远程 API在表格中点击<strong>激活</strong>Jarvis 社媒 AI 仅使用<strong>当前激活</strong>的一套未激活或清空全部时走 Jarvis 默认 Ollama</p>
<ul style="margin: 0; padding-left: 18px;">
<li><strong>Ollama</strong>根地址可空 Jarvis 默认模型可空</li>
<li><strong>OpenAI 兼容</strong>须填完整 Chat Completions URL 与模型名密钥可空</li>
</ul>
</div>
</el-alert>
<p v-if="llmSummary && llmSummary.redisAvailable !== false" class="llm-active-hint">
<template v-if="llmSummary.activeId">
当前 Jarvis 使用
<el-tag type="success" size="small">{{ llmActiveName || llmSummary.activeId }}</el-tag>
</template>
<template v-else>
<el-tag type="info" size="small">未激活</el-tag>
<span style="margin-left: 8px; color: #909399;">将使用 Jarvis yml 默认 Ollama</span>
</template>
</p>
<el-table
v-loading="llmListLoading"
:data="llmProfileRows"
border
stripe
empty-text="暂无配置请点击新增配置"
style="width: 100%;">
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="方式" width="108">
<template slot-scope="scope">
{{ scope.row.mode === 'openai' ? 'OpenAI兼容' : 'Ollama' }}
</template>
</el-table-column>
<el-table-column prop="baseUrl" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="model" label="模型" min-width="120" show-overflow-tooltip />
<el-table-column label="密钥" width="100">
<template slot-scope="scope">
{{ scope.row.hasApiKey ? (scope.row.apiKeyMasked || '已设') : '—' }}
</template>
</el-table-column>
<el-table-column label="当前" width="88" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.active" type="success" size="mini">使用中</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" width="268" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" :disabled="scope.row.active" @click="handleActivateLlm(scope.row)">激活</el-button>
<el-button type="text" size="small" @click="openLlmTest(scope.row)">测试</el-button>
<el-button type="text" size="small" @click="openLlmDialog(true, scope.row)">编辑</el-button>
<el-button type="text" size="small" class="llm-del-btn" @click="handleDeleteLlm(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="llm-toolbar-bottom">
<el-button size="small" @click="handleClearActiveLlm">取消激活</el-button>
<el-button type="danger" plain size="small" @click="handleResetAllLlm">清空全部配置</el-button>
</div>
<el-dialog
:title="llmDialogEditId ? '编辑大模型配置' : '新增大模型配置'"
:visible.sync="llmDialogVisible"
width="580px"
append-to-body
@close="resetLlmDialogForm">
<el-form :model="llmForm" label-width="140px" class="llm-form">
<el-form-item label="配置名称" required>
<el-input v-model="llmForm.name" placeholder="如:本机 Ollama、DeepSeek 官方" maxlength="80" show-word-limit />
</el-form-item>
<el-form-item label="接入方式">
<el-radio-group v-model="llmForm.mode">
<el-radio label="ollama">Ollama</el-radio>
<el-radio label="openai">OpenAI 兼容</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="llmForm.mode === 'openai' ? 'API 完整地址' : 'Ollama 根地址'">
<el-input
v-model="llmForm.baseUrl"
:placeholder="llmForm.mode === 'openai' ? 'https://api.openai.com/v1/chat/completions' : 'http://127.0.0.1:11434可空'"
clearable />
</el-form-item>
<el-form-item label="模型名称">
<el-input
v-model="llmForm.model"
:placeholder="llmForm.mode === 'openai' ? '必填' : '可空'"
clearable />
</el-form-item>
<el-form-item label="API 密钥">
<el-input
v-model="llmForm.apiKeyInput"
type="password"
show-password
autocomplete="new-password"
:placeholder="llmDialogEditId ? '留空不修改;填写则覆盖' : '可选'" />
<span v-if="llmDialogEditId && llmHasApiKey" class="llm-key-hint">已保存{{ llmApiKeyMasked || '****' }}</span>
</el-form-item>
<el-form-item v-if="llmDialogEditId && llmHasApiKey">
<el-checkbox v-model="llmClearApiKey">清除已保存的密钥</el-checkbox>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="llmDialogVisible = false"> </el-button>
<el-button type="primary" :loading="llmSaving" @click="submitLlmDialog"> </el-button>
</div>
</el-dialog>
<el-dialog
title="大模型连通测试"
:visible.sync="llmTestDialogVisible"
width="560px"
append-to-body
@close="resetLlmTestDialog">
<p v-if="llmTestRow" class="llm-test-hint">
将使用配置<strong>{{ llmTestRow.name }}</strong>的参数请求与是否已激活无关
</p>
<p v-else class="llm-test-hint">
未指定某一套配置将使用 Jarvis 当前<strong>已激活</strong>的一套若未激活则使用 Jarvis <strong>application.yml</strong> 默认 Ollama
</p>
<el-form label-width="88px">
<el-form-item label="测试问题">
<el-input v-model="llmTestMessage" type="textarea" :rows="4" placeholder="默认会询问 1+1可改成任意短句做连通性验证" />
</el-form-item>
</el-form>
<div v-if="llmTestResult" class="llm-test-result">
<el-alert v-if="llmTestResult.success" title="模型回复" type="success" :closable="false" show-icon>
<pre class="llm-test-reply">{{ llmTestResult.reply }}</pre>
</el-alert>
<el-alert v-else :description="llmTestResult.error || '请求失败'" title="调用失败" type="error" :closable="false" show-icon />
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="llmTestDialogVisible = false"> </el-button>
<el-button type="primary" :loading="llmTestLoading" icon="el-icon-position" @click="submitLlmTest">发送测试</el-button>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import {
listPromptTemplates,
getPromptTemplate,
savePromptTemplate,
deletePromptTemplate,
listLlmProfiles,
getLlmProfile,
createLlmProfile,
updateLlmProfile,
deleteLlmProfile,
setActiveLlmProfile,
clearActiveLlmProfile,
resetAllLlmConfig,
testLlmProfile
} from '@/api/jarvis/socialMediaPrompt'
export default {
name: 'SocialMediaPromptConfig',
data() {
return {
showHelp: false,
activeTab: 'keywords',
templates: {},
saving: {},
llmSummary: null,
llmProfileRows: [],
llmListLoading: false,
llmDialogVisible: false,
llmDialogEditId: null,
llmForm: {
name: '',
mode: 'ollama',
baseUrl: '',
model: '',
apiKeyInput: ''
},
llmHasApiKey: false,
llmApiKeyMasked: null,
llmClearApiKey: false,
llmSaving: false,
llmTestDialogVisible: false,
llmTestRow: null,
llmTestMessage: '1+1等于几请只回答一个数字或最简结果不要多余解释。',
llmTestLoading: false,
llmTestResult: null
}
},
computed: {
llmActiveName() {
const r = this.llmProfileRows.find(p => p.active)
return r ? r.name : ''
}
},
mounted() {
this.loadTemplates()
this.loadLlmList()
},
methods: {
/** 加载所有模板 */
async loadTemplates() {
try {
const res = await listPromptTemplates()
if (res.code === 200 && res.data) {
this.templates = res.data
// 初始化保存状态
Object.keys(this.templates).forEach(key => {
this.$set(this.saving, key, false)
})
} else {
this.$message.error(res.msg || '加载模板失败')
}
} catch (error) {
console.error('加载模板失败', error)
this.$message.error('加载模板失败:' + (error.message || '未知错误'))
}
},
/** 加载单个模板 */
async handleLoadTemplate(key) {
try {
const res = await getPromptTemplate(key)
if (res.code === 200 && res.data) {
this.$set(this.templates, key, res.data)
this.$message.success('重新加载成功')
} else {
this.$message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载模板失败', error)
this.$message.error('加载失败:' + (error.message || '未知错误'))
}
},
/** 保存模板 */
async handleSaveTemplate(key) {
const template = this.templates[key]
if (!template || !template.template || template.template.trim() === '') {
this.$message.warning('模板内容不能为空')
return
}
this.$set(this.saving, key, true)
try {
const res = await savePromptTemplate({
key: key,
template: template.template.trim()
})
if (res.code === 200) {
this.$message.success('保存成功!')
// 更新状态
this.$set(template, 'isDefault', false)
// 重新加载以确认
await this.handleLoadTemplate(key)
} else {
this.$message.error(res.msg || '保存失败')
}
} catch (error) {
console.error('保存模板失败', error)
this.$message.error('保存失败:' + (error.message || '未知错误'))
} finally {
this.$set(this.saving, key, false)
}
},
/** 恢复默认模板 */
async handleResetTemplate(key) {
try {
await this.$confirm('确定要恢复默认模板吗?自定义模板将被删除。', '提示', {
type: 'warning'
})
const res = await deletePromptTemplate(key)
if (res.code === 200) {
this.$message.success('已恢复默认模板')
// 重新加载
await this.handleLoadTemplate(key)
} else {
this.$message.error(res.msg || '恢复失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('恢复默认模板失败', error)
this.$message.error('恢复失败:' + (error.message || '未知错误'))
}
}
},
/** 获取模板标签 */
getTemplateLabel(key) {
const labels = {
'keywords': '关键词提取',
'content:xhs': '小红书文案',
'content:douyin': '抖音文案',
'content:both': '通用文案',
'xianyu:wenan_base': '闲鱼·正文基础',
'xianyu:jiaonixiadan_extra': '闲鱼·教你下单尾部',
'xianyu:title_clean_regex': '闲鱼·标题清洗正则'
}
return labels[key] || key
},
async loadLlmList() {
this.llmListLoading = true
try {
const res = await listLlmProfiles()
if (res.code === 200 && res.data) {
this.llmSummary = res.data
this.llmProfileRows = Array.isArray(res.data.profiles) ? res.data.profiles : []
} else {
this.$message.error(res.msg || '加载大模型配置失败')
}
} catch (e) {
console.error(e)
this.$message.error('加载大模型配置失败:' + (e.message || '未知错误'))
} finally {
this.llmListLoading = false
}
},
resetLlmDialogForm() {
this.llmDialogEditId = null
this.llmForm = {
name: '',
mode: 'ollama',
baseUrl: '',
model: '',
apiKeyInput: ''
}
this.llmHasApiKey = false
this.llmApiKeyMasked = null
this.llmClearApiKey = false
},
async openLlmDialog(edit, row) {
if (this.llmSummary && this.llmSummary.redisAvailable === false) {
this.$message.warning('Redis 未配置,无法保存')
return
}
this.resetLlmDialogForm()
if (edit && row && row.id) {
this.llmDialogEditId = row.id
try {
const res = await getLlmProfile(row.id)
if (res.code !== 200 || !res.data) {
this.$message.error(res.msg || '加载失败')
return
}
const d = res.data
this.llmForm.name = d.name || ''
this.llmForm.mode = d.mode === 'openai' ? 'openai' : 'ollama'
this.llmForm.baseUrl = d.baseUrl || ''
this.llmForm.model = d.model || ''
this.llmHasApiKey = !!d.hasApiKey
this.llmApiKeyMasked = d.apiKeyMasked || null
} catch (e) {
console.error(e)
this.$message.error('加载失败')
return
}
}
this.llmDialogVisible = true
},
async submitLlmDialog() {
if (!this.llmForm.name || !this.llmForm.name.trim()) {
this.$message.warning('请填写配置名称')
return
}
if (this.llmForm.mode === 'openai') {
if (!this.llmForm.baseUrl || !this.llmForm.baseUrl.trim()) {
this.$message.warning('OpenAI 兼容须填写完整 API 地址')
return
}
if (!this.llmForm.model || !this.llmForm.model.trim()) {
this.$message.warning('OpenAI 兼容须填写模型名称')
return
}
}
const payload = {
name: this.llmForm.name.trim(),
mode: this.llmForm.mode,
baseUrl: (this.llmForm.baseUrl || '').trim(),
model: (this.llmForm.model || '').trim()
}
if (this.llmDialogEditId) {
if (this.llmClearApiKey) {
payload.clearApiKey = true
} else if (this.llmForm.apiKeyInput && this.llmForm.apiKeyInput.trim()) {
payload.apiKey = this.llmForm.apiKeyInput.trim()
}
} else if (this.llmForm.apiKeyInput && this.llmForm.apiKeyInput.trim()) {
payload.apiKey = this.llmForm.apiKeyInput.trim()
}
this.llmSaving = true
try {
let res
if (this.llmDialogEditId) {
res = await updateLlmProfile(this.llmDialogEditId, payload)
} else {
res = await createLlmProfile(payload)
}
if (res.code === 200) {
this.$message.success('保存成功')
this.llmDialogVisible = false
await this.loadLlmList()
} else {
this.$message.error(res.msg || '保存失败')
}
} catch (e) {
console.error(e)
this.$message.error('保存失败:' + (e.message || '未知错误'))
} finally {
this.llmSaving = false
}
},
async handleActivateLlm(row) {
try {
const res = await setActiveLlmProfile(row.id)
if (res.code === 200) {
this.$message.success(res.msg || '已激活')
await this.loadLlmList()
} else {
this.$message.error(res.msg || '操作失败')
}
} catch (e) {
console.error(e)
this.$message.error('操作失败:' + (e.message || '未知错误'))
}
},
async handleDeleteLlm(row) {
try {
await this.$confirm(`确定删除配置「${row.name || row.id}」?`, '提示', { type: 'warning' })
const res = await deleteLlmProfile(row.id)
if (res.code === 200) {
this.$message.success('已删除')
await this.loadLlmList()
} else {
this.$message.error(res.msg || '删除失败')
}
} catch (e) {
if (e !== 'cancel') {
console.error(e)
this.$message.error('删除失败:' + (e.message || '未知错误'))
}
}
},
async handleClearActiveLlm() {
try {
const res = await clearActiveLlmProfile()
if (res.code === 200) {
this.$message.success(res.msg || '已取消激活')
await this.loadLlmList()
} else {
this.$message.error(res.msg || '操作失败')
}
} catch (e) {
console.error(e)
this.$message.error('操作失败:' + (e.message || '未知错误'))
}
},
async handleResetAllLlm() {
try {
await this.$confirm('将删除所有大模型接入配置及旧版单键Jarvis 恢复为默认 Ollama。是否继续', '提示', { type: 'warning' })
const res = await resetAllLlmConfig()
if (res.code === 200) {
this.$message.success(res.msg || '已清空')
await this.loadLlmList()
} else {
this.$message.error(res.msg || '操作失败')
}
} catch (e) {
if (e !== 'cancel') {
console.error(e)
this.$message.error('操作失败:' + (e.message || '未知错误'))
}
}
},
resetLlmTestDialog() {
this.llmTestRow = null
this.llmTestResult = null
this.llmTestLoading = false
},
openLlmTest(row) {
if (this.llmSummary && this.llmSummary.redisAvailable === false) {
this.$message.warning('Redis 未配置时无法从后台写入多套参数;仍可直接测 Jarvis 默认,请确认 Jarvis 地址正确。')
}
this.llmTestRow = row || null
this.llmTestMessage = '1+1等于几请只回答一个数字或最简结果不要多余解释。'
this.llmTestResult = null
this.llmTestDialogVisible = true
},
async submitLlmTest() {
if (!this.llmTestMessage || !this.llmTestMessage.trim()) {
this.$message.warning('请填写测试问题')
return
}
const payload = { message: this.llmTestMessage.trim() }
if (this.llmTestRow && this.llmTestRow.id) {
payload.profileId = this.llmTestRow.id
}
this.llmTestLoading = true
this.llmTestResult = null
try {
const res = await testLlmProfile(payload)
if (res.code === 200 && res.data) {
this.llmTestResult = {
success: !!res.data.success,
reply: res.data.reply,
error: res.data.error
}
if (this.llmTestResult.success) {
this.$message.success('测试完成')
} else {
this.$message.error(this.llmTestResult.error || '调用失败')
}
} else {
this.$message.error(res.msg || '测试失败')
this.llmTestResult = { success: false, error: res.msg || '未知错误' }
}
} catch (e) {
console.error(e)
this.$message.error('测试失败:' + (e.message || '未知错误'))
this.llmTestResult = { success: false, error: e.message || '未知错误' }
} finally {
this.llmTestLoading = false
}
}
}
}
</script>
<style scoped>
.prompt-config-container {
padding: 20px;
}
.card-title {
font-size: 18px;
font-weight: bold;
}
.help-section {
margin-bottom: 20px;
}
.help-section ul {
margin: 10px 0;
padding-left: 20px;
}
.help-section li {
margin: 5px 0;
}
.template-list {
margin-top: 20px;
}
.template-editor {
padding: 20px;
}
.template-info {
margin-bottom: 20px;
}
.template-status {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.template-textarea {
margin-bottom: 20px;
}
.template-textarea >>> .el-textarea__inner {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.template-actions {
text-align: right;
}
.template-actions .el-button {
margin-left: 10px;
}
.llm-card .llm-form {
max-width: 720px;
}
.llm-key-hint {
display: block;
margin-top: 6px;
font-size: 12px;
color: #909399;
}
.llm-active-hint {
margin-bottom: 12px;
font-size: 14px;
}
.llm-toolbar-bottom {
margin-top: 14px;
}
.llm-toolbar-bottom .el-button {
margin-right: 10px;
}
.llm-del-btn {
color: #f56c6c;
}
.llm-test-hint {
margin: 0 0 12px 0;
font-size: 13px;
color: #606266;
line-height: 1.5;
}
.llm-test-result {
margin-top: 12px;
}
.llm-test-reply {
margin: 8px 0 0 0;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.5;
}
</style>