feat: 部分适配器支持视频生成

This commit is contained in:
foxhui
2025-12-22 01:53:30 +08:00
Unverified
parent 6f5b0a760e
commit fdd11f9c70
29 changed files with 306 additions and 188 deletions
+75 -41
View File
@@ -108,9 +108,37 @@ const handleImageChange = async (info) => {
}
};
// 解析 Markdown 图片
// 将 base64 转换为 blob URL
const base64ToBlob = (dataUri) => {
const arr = dataUri.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return URL.createObjectURL(new Blob([u8arr], { type: mime }));
};
// 解析内容中的 Markdown 图片和直接的视频 data URI
const parseMarkdownImages = (content) => {
if (!content) return { text: '', images: [] };
if (!content) return { text: '', images: [], videos: [] };
// 检测是否是直接的 data:video/ 内容(非 markdown 格式)
if (content.trim().startsWith('data:video/')) {
try {
const blobUrl = base64ToBlob(content.trim());
return {
text: '',
images: [],
videos: [{ src: blobUrl, type: 'video/mp4' }]
};
} catch (e) {
console.error('视频转换失败', e);
return { text: content, images: [], videos: [] };
}
}
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = [];
@@ -127,11 +155,10 @@ const parseMarkdownImages = (content) => {
// 添加图片
images.push({
alt: match[1] || '图片',
src: match[2]
src: match[2],
type: 'image'
});
// 不添加占位符,直接跳过图片的 markdown 语法
lastIndex = imageRegex.lastIndex;
}
@@ -142,7 +169,8 @@ const parseMarkdownImages = (content) => {
return {
text: textParts.join('').trim(),
images
images,
videos: []
};
};
@@ -544,49 +572,59 @@ onMounted(async () => {
</div>
<!-- 测试结果 -->
<!-- 流式模式实时显示内容 -->
<div v-if="chatStreamMode && apiTestResults.chat.status === 'loading'"
style="background: #fafafa; padding: 12px; border-radius: 4px; font-size: 12px; max-height: 400px; overflow-y: auto;">
<div style="color: #1890ff; margin-bottom: 8px;">
<LoadingOutlined /> 正在接收流式响应...
<!-- 加载中或成功统一显示内容 -->
<div v-if="apiTestResults.chat.status === 'loading' || apiTestResults.chat.status === 'success'">
<!-- 状态标签 -->
<div style="margin-bottom: 8px;">
<a-tag v-if="apiTestResults.chat.status === 'loading'" color="processing">
<LoadingOutlined /> {{ chatStreamMode ? '正在接收流式响应...' : '请求中,可能需要较长时间...' }}
</a-tag>
<a-tag v-else color="success">
<CheckCircleOutlined /> 成功
</a-tag>
</div>
<!-- 文本内容 -->
<pre v-if="parseMarkdownImages(chatStreamContent).text"
style="white-space: pre-wrap; word-break: break-all; margin: 0 0 8px 0; min-height: 50px;">{{
parseMarkdownImages(chatStreamContent).text || '等待内容...' }}</pre>
<!-- 图片展示 -->
<div v-if="parseMarkdownImages(chatStreamContent).images.length > 0"
style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(img, index) in parseMarkdownImages(chatStreamContent).images" :key="index"
style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div style="font-size: 11px; color: #8c8c8c; margin-bottom: 4px;">{{ img.alt }}</div>
<img :src="img.src" :alt="img.alt"
style="max-width: 100%; height: auto; display: block; border-radius: 2px;" />
</div>
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div
style="margin-top: 8px; font-size: 12px; max-height: 400px; overflow-y: auto; background: #fafafa; padding: 8px; border-radius: 4px;">
<!-- 内容显示容器流式用 chatStreamContent成功后用 data.content -->
<div v-if="chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content"
style="font-size: 12px; max-height: 400px; overflow-y: auto; background: #fafafa; padding: 8px; border-radius: 4px;">
<!-- 文本内容 -->
<pre v-if="parseMarkdownImages(apiTestResults.chat.data?.content).text"
<pre
v-if="parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).text"
style="white-space: pre-wrap; word-break: break-all; margin: 0 0 8px 0;">{{
parseMarkdownImages(apiTestResults.chat.data?.content).text }}</pre>
parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).text }}</pre>
<!-- 图片展示 -->
<div v-if="parseMarkdownImages(apiTestResults.chat.data?.content).images.length > 0"
<div
v-if="parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).images.length > 0"
style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(img, index) in parseMarkdownImages(apiTestResults.chat.data?.content).images" :key="index"
style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div
v-for="(img, index) in parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).images"
:key="index" style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div style="font-size: 11px; color: #8c8c8c; margin-bottom: 4px;">{{ img.alt }}</div>
<img :src="img.src" :alt="img.alt"
style="max-width: 100%; height: auto; display: block; border-radius: 2px;" />
</div>
</div>
<!-- 视频展示 -->
<div
v-if="parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).videos.length > 0"
style="display: flex; flex-direction: column; gap: 8px;">
<div
v-for="(video, index) in parseMarkdownImages(chatStreamMode ? chatStreamContent : apiTestResults.chat.data?.content).videos"
:key="'video-' + index"
style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div style="font-size: 11px; color: #8c8c8c; margin-bottom: 4px;">生成的视频</div>
<video :src="video.src" controls
style="max-width: 100%; height: auto; display: block; border-radius: 2px;">
您的浏览器不支持视频播放
</video>
</div>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="apiTestResults.chat.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
@@ -595,10 +633,6 @@ onMounted(async () => {
{{ apiTestResults.chat.error }}
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'loading' && !chatStreamMode"
style="color: #1890ff; font-size: 12px;">
<LoadingOutlined /> 请求中可能需要较长时间...
</div>
</a-card>
</a-space>
</a-drawer>
+2 -2
View File
@@ -59,13 +59,13 @@ const handleLogin = async () => {
WebAI2API 管理面板
</div>
<div style="color: #8c8c8c; margin-top: 8px;">
请输入访问 Token 以继续
请输入访问 API Token 以继续
</div>
</div>
<a-form layout="vertical">
<a-form-item label="Access Token">
<a-input-password v-model:value="token" placeholder="请输入 Token" size="large"
<a-input-password v-model:value="token" placeholder="请输入 API Token" size="large"
@pressEnter="handleLogin">
<template #prefix>
<LockOutlined style="color: rgba(0,0,0,.25)" />
+26 -9
View File
@@ -22,19 +22,36 @@ onMounted(async () => {
]);
});
// 计算属性:适配器选项
// 计算属性:适配器选项(包含 merge
const adapterOptions = computed(() => {
const options = settingsStore.adaptersMeta.map(a => ({
label: a.name,
label: a.displayName || a.id,
value: a.id
}));
// 手动添加 Merge 选项(如果没有在元数据中返回)
// Merge 选项放在第一个位置
if (!options.find(o => o.value === 'merge')) {
options.push({ label: 'Merge(聚合模式)', value: 'merge' });
options.unshift({ label: 'Merge(聚合模式)', value: 'merge' });
}
return options;
});
// 计算属性:可聚合的适配器选项(不包含 merge,避免套娃)
const mergeableAdapterOptions = computed(() => {
return settingsStore.adaptersMeta
.filter(a => a.id !== 'merge')
.map(a => ({
label: a.displayName || a.id,
value: a.id
}));
});
// 辅助函数:根据适配器 ID 获取 displayName
const getAdapterDisplayName = (id) => {
if (id === 'merge') return 'Merge(聚合模式)';
const adapter = settingsStore.adaptersMeta.find(a => a.id === id);
return adapter?.displayName || id;
};
// 实例列表表格列定义
const columns = [
{
@@ -419,11 +436,11 @@ const handleRemoveWorker = (index) => {
<div>
<div style="font-weight: 600;">{{ item.name }}</div>
<div style="font-size: 12px; color: #8c8c8c;">
类型: {{ item.type }}
类型: {{ getAdapterDisplayName(item.type) }}
<span v-if="item.type === 'merge'">
| 聚合: {{ item.mergeTypes?.join(', ') || '' }}
| 聚合: {{ item.mergeTypes?.map(getAdapterDisplayName).join(', ') || '' }}
<span v-if="item.mergeMonitor">
| 监控: {{ item.mergeMonitor }}
| 监控: {{ getAdapterDisplayName(item.mergeMonitor) }}
</span>
</span>
</div>
@@ -467,7 +484,7 @@ const handleRemoveWorker = (index) => {
选择要聚合的后端适配器可多选
</div>
<a-select v-model:value="workerForm.mergeTypes" mode="multiple" style="width: 100%"
placeholder="选择要聚合的适配器" :options="adapterOptions">
placeholder="选择要聚合的适配器" :options="mergeableAdapterOptions">
</a-select>
</div>
@@ -480,7 +497,7 @@ const handleRemoveWorker = (index) => {
allow-clear>
<a-select-option value=""></a-select-option>
<a-select-option v-for="type in workerForm.mergeTypes" :key="type" :value="type">
{{ type }}
{{ getAdapterDisplayName(type) }}
</a-select-option>
</a-select>
</div>