完善模型测试与参考文件历史能力
This commit is contained in:
@@ -2,7 +2,9 @@ import http from './index'
|
||||
|
||||
export const generateApi = {
|
||||
test: (data: any) => http.post('/generate/test', data),
|
||||
testStream: () => '/api/v1/generate/test-stream',
|
||||
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
|
||||
full: (data: any) => http.post('/generate/full', data),
|
||||
progress: (id: number) => `/api/v1/generate/progress/${id}`,
|
||||
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface Paragraph {
|
||||
|
||||
export interface AiModel {
|
||||
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
|
||||
api_endpoint: string; api_key_preview: string; status: 'enabled' | 'disabled'
|
||||
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -23,5 +23,9 @@ export interface Document {
|
||||
file_path: string; error: string
|
||||
}
|
||||
|
||||
export interface ReferenceFile {
|
||||
id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> { code: number; data: T; message: string }
|
||||
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<div class="mc-name">{{ item.name }}</div>
|
||||
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
|
||||
<div class="mc-provider">密钥:{{ item.api_key_preview || '未设置' }}</div>
|
||||
<div class="mc-provider">流式传输:{{ item.supports_streaming ? '支持' : '关闭' }}</div>
|
||||
<div class="mc-provider">思考模式:{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
|
||||
</div>
|
||||
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
|
||||
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
|
||||
@@ -48,6 +50,12 @@
|
||||
<a-form-item label="API 地址">
|
||||
<a-input v-model:value="form.api_endpoint" />
|
||||
</a-form-item>
|
||||
<a-form-item label="支持流式传输">
|
||||
<a-switch v-model:checked="form.supports_streaming" />
|
||||
</a-form-item>
|
||||
<a-form-item label="开启思考模式">
|
||||
<a-switch v-model:checked="form.enable_reasoning" />
|
||||
</a-form-item>
|
||||
<a-form-item label="API Key">
|
||||
<a-input-password v-model:value="form.api_key" />
|
||||
</a-form-item>
|
||||
@@ -66,7 +74,7 @@ const models = ref<any[]>([])
|
||||
const modalOpen = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref(0)
|
||||
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' })
|
||||
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
|
||||
|
||||
async function refreshList() {
|
||||
await store.fetchList()
|
||||
@@ -79,7 +87,7 @@ onMounted(async () => {
|
||||
|
||||
function openAdd() {
|
||||
isEdit.value = false
|
||||
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' }
|
||||
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -92,6 +100,8 @@ function openEdit(item: any) {
|
||||
api_format: item.api_format,
|
||||
api_endpoint: item.api_endpoint,
|
||||
api_key: '',
|
||||
supports_streaming: !!item.supports_streaming,
|
||||
enable_reasoning: !!item.enable_reasoning,
|
||||
}
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false">
|
||||
<p class="ant-upload-drag-icon"><upload-outlined /></p>
|
||||
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
|
||||
<p class="ant-upload-hint">支持多文件:docx / xlsx / xls / csv / pdf / txt / md</p>
|
||||
<p class="ant-upload-hint">支持多文件:docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
|
||||
</a-upload-dragger>
|
||||
</div>
|
||||
|
||||
@@ -151,12 +151,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-button type="primary" block :loading="testing" @click="startTest">开始测试</a-button>
|
||||
<div class="history-card">
|
||||
<div class="history-head">
|
||||
<div>
|
||||
<div class="history-title">历史文件</div>
|
||||
<div class="history-desc">已上传过的文件会保存在系统里,下次可直接选择复用。</div>
|
||||
</div>
|
||||
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
|
||||
</div>
|
||||
|
||||
<div class="history-search">
|
||||
<a-input-search
|
||||
v-model:value="historyKeyword"
|
||||
placeholder="按文件名搜索历史文件"
|
||||
allow-clear
|
||||
@search="fetchReferenceHistory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="historyLoading">
|
||||
<div v-if="referenceHistory.length" class="history-list">
|
||||
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedHistoryPaths.includes(item.file_path)"
|
||||
@change="toggleHistoryFile(item.file_path)"
|
||||
/>
|
||||
<div class="history-item-main">
|
||||
<div class="history-item-name">{{ item.file_name }}</div>
|
||||
<div class="history-item-meta">
|
||||
<span>{{ formatFileSize(item.file_size) }}</span>
|
||||
<span>{{ formatDateTime(item.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<a-empty v-else description="暂无历史文件" />
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedHistoryItems.length" class="uploaded-list">
|
||||
<div class="uploaded-item" v-for="item in selectedHistoryItems" :key="item.file_path">
|
||||
<span class="uploaded-name">{{ item.file_name }}</span>
|
||||
<a-button type="link" size="small" danger @click="toggleHistoryFile(item.file_path)">取消选择</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-button type="primary" block :loading="testing" @click="startTest">开始测试(支持多文件)</a-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testStep === 1" class="test-processing">
|
||||
<a-spin size="large" />
|
||||
<p class="processing-text">{{ testStatusText }}</p>
|
||||
<div v-if="streamedText" class="streaming-card">
|
||||
<div class="streaming-title">模型实时返回内容</div>
|
||||
<pre class="streaming-pre">{{ streamedText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -194,6 +244,7 @@ import {
|
||||
import { useTemplateStore } from '@/stores/template'
|
||||
import { useModelStore } from '@/stores/model'
|
||||
import { generateApi } from '@/api/generate'
|
||||
import type { ReferenceFile } from '@/types'
|
||||
|
||||
interface LocalUploadFile {
|
||||
uid: string
|
||||
@@ -219,8 +270,16 @@ const testFiles = ref<LocalUploadFile[]>([])
|
||||
const testResultHtml = ref('')
|
||||
const testResultMessage = ref('')
|
||||
const testFileSummaries = ref<any[]>([])
|
||||
const streamedText = ref('')
|
||||
const referenceHistory = ref<ReferenceFile[]>([])
|
||||
const selectedHistoryPaths = ref<string[]>([])
|
||||
const historyKeyword = ref('')
|
||||
const historyLoading = ref(false)
|
||||
|
||||
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
|
||||
const selectedHistoryItems = computed(() =>
|
||||
referenceHistory.value.filter((item) => selectedHistoryPaths.value.includes(item.file_path))
|
||||
)
|
||||
|
||||
function selectPara(id: number) {
|
||||
selectedId.value = id
|
||||
@@ -240,6 +299,8 @@ function openTestModal() {
|
||||
testResultHtml.value = ''
|
||||
testResultMessage.value = ''
|
||||
testFileSummaries.value = []
|
||||
selectedHistoryPaths.value = []
|
||||
fetchReferenceHistory()
|
||||
}
|
||||
|
||||
function resetTestModal() {
|
||||
@@ -250,6 +311,8 @@ function resetTestModal() {
|
||||
testResultHtml.value = ''
|
||||
testResultMessage.value = ''
|
||||
testFileSummaries.value = []
|
||||
streamedText.value = ''
|
||||
selectedHistoryPaths.value = []
|
||||
}
|
||||
|
||||
function beforeTestUpload(file: File) {
|
||||
@@ -265,6 +328,40 @@ function removeTestFile(uid: string) {
|
||||
testFiles.value = testFiles.value.filter((item) => item.uid !== uid)
|
||||
}
|
||||
|
||||
function toggleHistoryFile(filePath: string) {
|
||||
if (selectedHistoryPaths.value.includes(filePath)) {
|
||||
selectedHistoryPaths.value = selectedHistoryPaths.value.filter((item) => item !== filePath)
|
||||
return
|
||||
}
|
||||
selectedHistoryPaths.value = [...selectedHistoryPaths.value, filePath]
|
||||
}
|
||||
|
||||
function formatFileSize(fileSize: number) {
|
||||
if (fileSize < 1024) return `${fileSize} B`
|
||||
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
async function fetchReferenceHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const response: any = await generateApi.referenceFiles({
|
||||
page: 1,
|
||||
page_size: 30,
|
||||
keyword: historyKeyword.value.trim(),
|
||||
})
|
||||
referenceHistory.value = response.data?.items || []
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '加载历史文件失败')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderTestResult(content: any) {
|
||||
const blocks = content?.content || []
|
||||
return blocks
|
||||
@@ -291,7 +388,7 @@ function renderTestResult(content: any) {
|
||||
|
||||
async function startTest() {
|
||||
if (!selectedPara.value) return
|
||||
if (selectedPara.value.need_file && !testFiles.value.length) {
|
||||
if (selectedPara.value.need_file && !testFiles.value.length && !selectedHistoryPaths.value.length) {
|
||||
message.warning('请先上传至少一个参考文件')
|
||||
return
|
||||
}
|
||||
@@ -308,21 +405,34 @@ async function startTest() {
|
||||
const uploadRes: any = await generateApi.upload(formData)
|
||||
filePaths.push(uploadRes.data.file_path)
|
||||
}
|
||||
for (const filePath of selectedHistoryPaths.value) {
|
||||
if (!filePaths.includes(filePath)) {
|
||||
filePaths.push(filePath)
|
||||
}
|
||||
}
|
||||
await fetchReferenceHistory()
|
||||
|
||||
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
|
||||
const templateId = Number(route.params.id)
|
||||
const response: any = await generateApi.test({
|
||||
paragraph_id: selectedPara.value.id,
|
||||
template_id: templateId,
|
||||
prompt_text: selectedPara.value.prompt_text || '',
|
||||
model_id: selectedPara.value.model_id || 0,
|
||||
file_paths: filePaths,
|
||||
})
|
||||
const currentModel = models.value.find((item) => item.id === selectedPara.value.model_id)
|
||||
if (currentModel?.supports_streaming) {
|
||||
testStatusText.value = '正在流式接收模型返回内容...'
|
||||
streamedText.value = ''
|
||||
await runStreamingTest(templateId, filePaths)
|
||||
} else {
|
||||
const response: any = await generateApi.test({
|
||||
paragraph_id: selectedPara.value.id,
|
||||
template_id: templateId,
|
||||
prompt_text: selectedPara.value.prompt_text || '',
|
||||
model_id: selectedPara.value.model_id || 0,
|
||||
file_paths: filePaths,
|
||||
})
|
||||
|
||||
testResultMessage.value = response.data?.message || '测试完成'
|
||||
testFileSummaries.value = response.data?.file_summaries || []
|
||||
testResultHtml.value = renderTestResult(response.data?.content)
|
||||
testStep.value = 2
|
||||
testResultMessage.value = response.data?.message || '测试完成'
|
||||
testFileSummaries.value = response.data?.file_summaries || []
|
||||
testResultHtml.value = renderTestResult(response.data?.content)
|
||||
testStep.value = 2
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '段落测试失败')
|
||||
resetTestModal()
|
||||
@@ -331,6 +441,68 @@ async function startTest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runStreamingTest(templateId: number, filePaths: string[]) {
|
||||
const response = await fetch(generateApi.testStream(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paragraph_id: selectedPara.value.id,
|
||||
template_id: templateId,
|
||||
prompt_text: selectedPara.value.prompt_text || '',
|
||||
model_id: selectedPara.value.model_id || 0,
|
||||
file_paths: filePaths,
|
||||
}),
|
||||
})
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error('流式测试启动失败')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
const processEventBlock = (block: string) => {
|
||||
const normalizedBlock = block.replace(/\r\n/g, '\n').trim()
|
||||
if (!normalizedBlock) return
|
||||
const dataLines = normalizedBlock
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data: '))
|
||||
.map((line) => line.slice(6))
|
||||
if (!dataLines.length) return
|
||||
|
||||
const payload = JSON.parse(dataLines.join('\n'))
|
||||
if (payload.type === 'meta') {
|
||||
testFileSummaries.value = payload.file_summaries || []
|
||||
testResultMessage.value = payload.message || '流式生成中'
|
||||
} else if (payload.type === 'delta') {
|
||||
streamedText.value += payload.content || ''
|
||||
} else if (payload.type === 'error') {
|
||||
throw new Error(payload.message || '流式测试失败')
|
||||
} else if (payload.type === 'done') {
|
||||
testResultHtml.value = `<pre class="streamed-pre">${streamedText.value}</pre>`
|
||||
testStep.value = 2
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const segments = buffer.split(/\r?\n\r?\n/)
|
||||
buffer = segments.pop() || ''
|
||||
for (const segment of segments) {
|
||||
processEventBlock(segment)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
processEventBlock(buffer)
|
||||
}
|
||||
if (!testResultHtml.value) {
|
||||
testResultHtml.value = `<pre class="streamed-pre">${streamedText.value}</pre>`
|
||||
testStep.value = 2
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
const template = await store.fetchOne(id)
|
||||
@@ -654,6 +826,75 @@ onMounted(async () => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.history-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.history-search {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-item-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.uploaded-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -678,6 +919,29 @@ onMounted(async () => {
|
||||
color: #5b626e;
|
||||
}
|
||||
|
||||
.streaming-card {
|
||||
margin-top: 20px;
|
||||
background: #f0f0ff;
|
||||
border-left: 3px solid #5b5bd6;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.streaming-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5b5bd6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.streaming-pre {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -739,6 +1003,13 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.streamed-pre) {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
:deep(.result-text) {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.8;
|
||||
|
||||
Reference in New Issue
Block a user