!145 refactor(web-antd): 修正 Tinyflow 组件中的导入路径

Merge pull request !145 from gjd/dev_xx
pull/146/head^2
xingyu 2025-06-16 05:44:10 +00:00 committed by Gitee
commit 81e63c7204
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
128 changed files with 33362 additions and 363 deletions

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715352878351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1499" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M624.5 786.3c92.9 0 168.2-75.3 168.2-168.2V309c0-92.4-75.3-168.2-168.2-168.2H303.6c-92.4 0-168.2 75.3-168.2 168.2v309.1c0 92.4 75.3 168.2 168.2 168.2h320.9zM178.2 618.1V309c0-69.4 56.1-125.5 125.5-125.5h320.9c69.4 0 125.5 56.1 125.5 125.5v309.1c0 69.4-56.1 125.5-125.5 125.5h-321c-69.4 0-125.4-56.1-125.4-125.5z" p-id="1500" fill="#8a8a8a"></path><path d="M849.8 295.1v361.5c0 102.7-83.6 186.3-186.3 186.3H279.1v42.7h384.4c126.3 0 229.1-102.8 229.1-229.1V295.1h-42.8zM307.9 361.8h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4zM307.9 484.6h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4z" p-id="1501" fill="#8a8a8a"></path><path d="M620.2 607.4c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.8 9.6 21.4 21.4 21.4h312.3z" p-id="1502" fill="#8a8a8a"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716345268026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5622" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M956.408445 419.226665a250.670939 250.670939 0 0 0-22.425219-209.609236A263.163526 263.163526 0 0 0 652.490412 85.715535 259.784384 259.784384 0 0 0 457.728923 0.008192a261.422756 261.422756 0 0 0-249.44216 178.582564 258.453206 258.453206 0 0 0-172.848261 123.901894c-57.03583 96.868753-44.031251 219.132275 32.153053 302.279661a250.670939 250.670939 0 0 0 22.32282 209.609237 263.163526 263.163526 0 0 0 281.595213 123.901893A259.067596 259.067596 0 0 0 566.271077 1023.990784a260.60357 260.60357 0 0 0 249.339762-178.889759 258.453206 258.453206 0 0 0 172.848261-123.901893c57.445423-96.868753 44.13365-218.82508-32.050655-302.074865zM566.578272 957.124721c-45.362429 0-89.496079-15.666934-124.516283-44.543243 1.638372-0.921584 4.198329-2.150363 6.143895-3.481541l206.537289-117.757998a32.35785 32.35785 0 0 0 16.895713-29.081105V474.82892l87.243317 49.97035c1.023983 0.307195 1.638372 1.228779 1.638372 2.252762v238.075953c0 105.8798-86.936122 191.689541-193.942303 191.996736zM148.588578 781.102113a189.846373 189.846373 0 0 1-23.346803-128.612213c1.535974 1.023983 4.09593 2.559956 6.143895 3.48154L337.922959 773.729439c10.444622 6.143896 23.346803 6.143896 34.098621 0l252.30931-143.664758v99.531108c0 1.023983-0.307195 1.945567-1.331177 2.559956l-208.892449 118.986778a196.297463 196.297463 0 0 1-265.518686-70.04041zM94.112704 335.97688c22.630015-39.013737 58.367008-68.81163 101.16948-84.171369V494.591784c0 11.7758 6.45109 22.93721 16.793315 28.978707l252.30931 143.767156L377.141493 716.796006a3.174346 3.174346 0 0 1-2.867152 0.307195l-208.892448-118.986777A190.870355 190.870355 0 0 1 94.215102 335.874482z m717.607001 164.861198L559.410394 357.070922 646.653711 307.20297a3.174346 3.174346 0 0 1 2.969549-0.307195l208.892449 118.986777a190.358364 190.358364 0 0 1 70.961994 262.139544 194.556693 194.556693 0 0 1-101.16948 84.171369V529.407192a31.538664 31.538664 0 0 0-16.588518-28.671513z m87.03852-129.329002c-1.74077-1.023983-4.300727-2.559956-6.246294-3.48154l-206.639687-117.757999a34.09862 34.09862 0 0 0-33.996222 0L399.566711 393.934295v-99.531108c0-1.023983 0.307195-1.945567 1.331178-2.559956l208.892449-119.089176a195.990268 195.990268 0 0 1 265.518686 70.450003c22.732414 38.706542 31.129071 84.171369 23.346803 128.305018zM352.258716 548.862861l-87.243317-49.560757a2.457558 2.457558 0 0 1-1.638372-2.252762V258.870991c0-105.8798 87.243317-191.996736 194.556692-191.689541a194.556693 194.556693 0 0 1 124.209089 44.543243c-1.638372 0.921584-4.198329 2.252762-6.143896 3.48154l-206.639687 117.757999a31.948257 31.948257 0 0 0-16.793315 29.081105l-0.307194 286.715126z m47.307995-100.759887L512 384.001664l112.535687 63.998912v127.997824l-112.228492 63.998912-112.535687-63.998912-0.307195-127.997824z" p-id="5623" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -0,0 +1,75 @@
import type { PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiChatConversationApi {
export interface ChatConversationVO {
id: number; // ID 编号
userId: number; // 用户编号
title: string; // 对话标题
pinned: boolean; // 是否置顶
roleId: number; // 角色编号
modelId: number; // 模型编号
model: string; // 模型标志
temperature: number; // 温度参数
maxTokens: number; // 单条回复的最大 Token 数量
maxContexts: number; // 上下文的最大 Message 数量
createTime?: Date; // 创建时间
// 额外字段
systemMessage?: string; // 角色设定
modelName?: string; // 模型名字
roleAvatar?: string; // 角色头像
modelMaxTokens?: string; // 模型的单条回复的最大 Token 数量
modelMaxContexts?: string; // 模型的上下文的最大 Message 数量
}
}
// 获得【我的】聊天对话
export function getChatConversationMy(id: number) {
return requestClient.get<AiChatConversationApi.ChatConversationVO>(
`/ai/chat/conversation/get-my?id=${id}`,
);
}
// 新增【我的】聊天对话
export function createChatConversationMy(
data: AiChatConversationApi.ChatConversationVO,
) {
return requestClient.post('/ai/chat/conversation/create-my', data);
}
// 更新【我的】聊天对话
export function updateChatConversationMy(
data: AiChatConversationApi.ChatConversationVO,
) {
return requestClient.put(`/ai/chat/conversation/update-my`, data);
}
// 删除【我的】聊天对话
export function deleteChatConversationMy(id: number) {
return requestClient.delete(`/ai/chat/conversation/delete-my?id=${id}`);
}
// 删除【我的】所有对话,置顶除外
export function deleteChatConversationMyByUnpinned() {
return requestClient.delete(`/ai/chat/conversation/delete-by-unpinned`);
}
// 获得【我的】聊天对话列表
export function getChatConversationMyList() {
return requestClient.get<AiChatConversationApi.ChatConversationVO[]>(
`/ai/chat/conversation/my-list`,
);
}
// 获得【我的】聊天对话列表
export function getChatConversationPage(params: any) {
return requestClient.get<
PageResult<AiChatConversationApi.ChatConversationVO[]>
>(`/ai/chat/conversation/page`, { params });
}
// 管理员删除消息
export function deleteChatConversationByAdmin(id: number) {
return requestClient.delete(`/ai/chat/conversation/delete-by-admin?id=${id}`);
}

View File

@ -0,0 +1,95 @@
import type { PageResult } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiChatMessageApi {
export interface ChatMessageVO {
id: number; // 编号
conversationId: number; // 对话编号
type: string; // 消息类型
userId: string; // 用户编号
roleId: string; // 角色编号
model: number; // 模型标志
modelId: number; // 模型编号
content: string; // 聊天内容
tokens: number; // 消耗 Token 数量
segmentIds?: number[]; // 段落编号
segments?: {
content: string; // 段落内容
documentId: number; // 文档编号
documentName: string; // 文档名称
id: number; // 段落编号
}[];
createTime: Date; // 创建时间
roleAvatar: string; // 角色头像
userAvatar: string; // 用户头像
}
}
// 消息列表
export function getChatMessageListByConversationId(
conversationId: null | number,
) {
return requestClient.get<AiChatMessageApi.ChatMessageVO[]>(
`/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`,
);
}
// 发送 Stream 消息
export function sendChatMessageStream(
conversationId: number,
content: string,
ctrl: any,
enableContext: boolean,
onMessage: any,
onError: any,
onClose: any,
) {
const token = accessStore.accessToken;
return fetchEventSource(`${apiURL}/ai/chat/message/send-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify({
conversationId,
content,
useContext: enableContext,
}),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 删除消息
export function deleteChatMessage(id: number) {
return requestClient.delete(`/ai/chat/message/delete?id=${id}`);
}
// 删除指定对话的消息
export function deleteByConversationId(conversationId: number) {
return requestClient.delete(
`/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`,
);
}
// 获得消息分页
export function getChatMessagePage(params: any) {
return requestClient.get<PageResult<AiChatMessageApi.ChatMessageVO>>(
'/ai/chat/message/page',
{ params },
);
}
// 管理员删除消息
export function deleteChatMessageByAdmin(id: number) {
return requestClient.delete(`/ai/chat/message/delete-by-admin?id=${id}`);
}

View File

@ -0,0 +1,112 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiImageApi {
export interface ImageMidjourneyButtonsVO {
customId: string; // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
emoji: string; // 图标 emoji
label: string; // Make Variations 文本
style: number; // 样式: 2Primary、3Green
}
// AI 绘图 VO
export interface ImageVO {
id: number; // 编号
platform: string; // 平台
model: string; // 模型
prompt: string; // 提示词
width: number; // 图片宽度
height: number; // 图片高度
status: number; // 状态
publicStatus: boolean; // 公开状态
picUrl: string; // 任务地址
errorMessage: string; // 错误信息
options: any; // 配置 Map<string, string>
taskId: number; // 任务编号
buttons: ImageMidjourneyButtonsVO[]; // mj 操作按钮
createTime: Date; // 创建时间
finishTime: Date; // 完成时间
}
export interface ImageDrawReqVO {
prompt: string; // 提示词
modelId: number; // 模型
style: string; // 图像生成的风格
width: string; // 图片宽度
height: string; // 图片高度
options: object; // 绘制参数Map<String, String>
}
export interface ImageMidjourneyImagineReqVO {
prompt: string; // 提示词
modelId: number; // 模型
base64Array?: string[]; // size不能为空
width: string; // 图片宽度
height: string; // 图片高度
version: string; // 版本
}
export interface ImageMidjourneyActionVO {
id: number; // 图片编号
customId: string; // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
}
}
// 获取【我的】绘图分页
export function getImagePageMy(params: PageParam) {
return requestClient.get<PageResult<AiImageApi.ImageVO>>(
'/ai/image/my-page',
{ params },
);
}
// 获取【我的】绘图记录
export function getImageMy(id: number) {
return requestClient.get<AiImageApi.ImageVO>(`/ai/image/get-my?id=${id}`);
}
// 获取【我的】绘图记录列表
export function getImageListMyByIds(ids: number[]) {
return requestClient.get<AiImageApi.ImageVO[]>(`/ai/image/my-list-by-ids`, {
params: { ids: ids.join(',') },
});
}
// 生成图片
export function drawImage(data: AiImageApi.ImageDrawReqVO) {
return requestClient.post(`/ai/image/draw`, data);
}
// 删除【我的】绘画记录
export function deleteImageMy(id: number) {
return requestClient.delete(`/ai/image/delete-my?id=${id}`);
}
// ================ midjourney 专属 ================
// 【Midjourney】生成图片
export function midjourneyImagine(
data: AiImageApi.ImageMidjourneyImagineReqVO,
) {
return requestClient.post(`/ai/image/midjourney/imagine`, data);
}
// 【Midjourney】Action 操作(二次生成图片)
export function midjourneyAction(data: AiImageApi.ImageMidjourneyActionVO) {
return requestClient.post(`/ai/image/midjourney/action`, data);
}
// ================ 绘图管理 ================
// 查询绘画分页
export function getImagePage(params: any) {
return requestClient.get<AiImageApi.ImageVO[]>(`/ai/image/page`, { params });
}
// 更新绘画发布状态
export function updateImage(data: any) {
return requestClient.put(`/ai/image/update`, data);
}
// 删除绘画
export function deleteImage(id: number) {
return requestClient.delete(`/ai/image/delete?id=${id}`);
}

View File

@ -0,0 +1,50 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiKnowledgeDocumentApi {
export interface KnowledgeDocumentVO {
id: number; // 编号
knowledgeId: number; // 知识库编号
name: string; // 文档名称
contentLength: number; // 字符数
tokens: number; // token 数
segmentMaxTokens: number; // 分片最大 token 数
retrievalCount: number; // 召回次数
status: number; // 是否启用
}
}
// 查询知识库文档分页
export function getKnowledgeDocumentPage(params: PageParam) {
return requestClient.get<
PageResult<AiKnowledgeDocumentApi.KnowledgeDocumentVO>
>('/ai/knowledge/document/page', { params });
}
// 查询知识库文档详情
export function getKnowledgeDocument(id: number) {
return requestClient.get(`/ai/knowledge/document/get?id=${id}`);
}
// 新增知识库文档(单个)
export function createKnowledge(data: any) {
return requestClient.post('/ai/knowledge/document/create', data);
}
// 新增知识库文档(多个)
export function createKnowledgeDocumentList(data: any) {
return requestClient.post('/ai/knowledge/document/create-list', data);
}
// 修改知识库文档
export function updateKnowledgeDocument(data: any) {
return requestClient.put('/ai/knowledge/document/update', data);
}
// 修改知识库文档状态
export function updateKnowledgeDocumentStatus(data: any) {
return requestClient.put('/ai/knowledge/document/update-status', data);
}
// 删除知识库文档
export function deleteKnowledgeDocument(id: number) {
return requestClient.delete(`/ai/knowledge/document/delete?id=${id}`);
}

View File

@ -0,0 +1,50 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiKnowledgeKnowledgeApi {
export interface KnowledgeVO {
id: number; // 编号
name: string; // 知识库名称
description: string; // 知识库描述
embeddingModelId: number; // 嵌入模型编号,高质量模式时维护
topK: number; // topK
similarityThreshold: number; // 相似度阈值
}
}
// 查询知识库分页
export function getKnowledgePage(params: PageParam) {
return requestClient.get<PageResult<AiKnowledgeKnowledgeApi.KnowledgeVO>>(
'/ai/knowledge/page',
{ params },
);
}
// 查询知识库详情
export function getKnowledge(id: number) {
return requestClient.get<AiKnowledgeKnowledgeApi.KnowledgeVO>(
`/ai/knowledge/get?id=${id}`,
);
}
// 新增知识库
export function createKnowledge(data: AiKnowledgeKnowledgeApi.KnowledgeVO) {
return requestClient.post('/ai/knowledge/create', data);
}
// 修改知识库
export function updateKnowledge(data: AiKnowledgeKnowledgeApi.KnowledgeVO) {
return requestClient.put('/ai/knowledge/update', data);
}
// 删除知识库
export function deleteKnowledge(id: number) {
return requestClient.delete(`/ai/knowledge/delete?id=${id}`);
}
// 获取知识库简单列表
export function getSimpleKnowledgeList() {
return requestClient.get<AiKnowledgeKnowledgeApi.KnowledgeVO[]>(
'/ai/knowledge/simple-list',
);
}

View File

@ -0,0 +1,76 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiKnowledgeSegmentApi {
// AI 知识库分段 VO
export interface KnowledgeSegmentVO {
id: number; // 编号
documentId: number; // 文档编号
knowledgeId: number; // 知识库编号
vectorId: string; // 向量库编号
content: string; // 切片内容
contentLength: number; // 切片内容长度
tokens: number; // token 数量
retrievalCount: number; // 召回次数
status: number; // 文档状态
createTime: number; // 创建时间
}
}
// 查询知识库分段分页
export function getKnowledgeSegmentPage(params: PageParam) {
return requestClient.get<
PageResult<AiKnowledgeSegmentApi.KnowledgeSegmentVO>
>('/ai/knowledge/segment/page', { params });
}
// 查询知识库分段详情
export function getKnowledgeSegment(id: number) {
return requestClient.get<AiKnowledgeSegmentApi.KnowledgeSegmentVO>(
`/ai/knowledge/segment/get?id=${id}`,
);
}
// 新增知识库分段
export function createKnowledgeSegment(
data: AiKnowledgeSegmentApi.KnowledgeSegmentVO,
) {
return requestClient.post('/ai/knowledge/segment/create', data);
}
// 修改知识库分段
export function updateKnowledgeSegment(
data: AiKnowledgeSegmentApi.KnowledgeSegmentVO,
) {
return requestClient.put('/ai/knowledge/segment/update', data);
}
// 修改知识库分段状态
export function updateKnowledgeSegmentStatus(data: any) {
return requestClient.put('/ai/knowledge/segment/update-status', data);
}
// 删除知识库分段
export function deleteKnowledgeSegment(id: number) {
return requestClient.delete(`/ai/knowledge/segment/delete?id=${id}`);
}
// 切片内容
export function splitContent(url: string, segmentMaxTokens: number) {
return requestClient.get('/ai/knowledge/segment/split', {
params: { url, segmentMaxTokens },
});
}
// 获取文档处理列表
export function getKnowledgeSegmentProcessList(documentIds: number[]) {
return requestClient.get('/ai/knowledge/segment/get-process-list', {
params: { documentIds: documentIds.join(',') },
});
}
// 搜索知识库分段
export function searchKnowledgeSegment(params: any) {
return requestClient.get('/ai/knowledge/segment/search', {
params,
});
}

View File

@ -0,0 +1,64 @@
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiMindmapApi {
// AI 思维导图 VO
export interface MindMapVO {
id: number; // 编号
userId: number; // 用户编号
prompt: string; // 生成内容提示
generatedContent: string; // 生成的思维导图内容
platform: string; // 平台
model: string; // 模型
errorMessage: string; // 错误信息
}
// AI 思维导图生成 VO
export interface AiMindMapGenerateReqVO {
prompt: string;
}
}
export function generateMindMap({
data,
onClose,
onMessage,
onError,
ctrl,
}: {
ctrl: AbortController;
data: AiMindmapApi.AiMindMapGenerateReqVO;
onClose?: (...args: any[]) => void;
onError?: (...args: any[]) => void;
onMessage?: (res: any) => void;
}) {
const token = accessStore.accessToken;
return fetchEventSource(`${apiURL}/ai/mind-map/generate-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 查询思维导图分页
export function getMindMapPage(params: any) {
return requestClient.get(`/ai/mind-map/page`, { params });
}
// 删除思维导图
export function deleteMindMap(id: number) {
return requestClient.delete(`/ai/mind-map/delete?id=${id}`);
}

View File

@ -0,0 +1,50 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiModelApiKeyApi {
export interface ApiKeyVO {
id: number; // 编号
name: string; // 名称
apiKey: string; // 密钥
platform: string; // 平台
url: string; // 自定义 API 地址
status: number; // 状态
}
}
// 查询 API 密钥分页
export function getApiKeyPage(params: PageParam) {
return requestClient.get<PageResult<AiModelApiKeyApi.ApiKeyVO>>(
'/ai/api-key/page',
{ params },
);
}
// 获得 API 密钥列表
export function getApiKeySimpleList() {
return requestClient.get<AiModelApiKeyApi.ApiKeyVO[]>(
'/ai/api-key/simple-list',
);
}
// 查询 API 密钥详情
export function getApiKey(id: number) {
return requestClient.get<AiModelApiKeyApi.ApiKeyVO>(
`/ai/api-key/get?id=${id}`,
);
}
// 新增 API 密钥
export function createApiKey(data: AiModelApiKeyApi.ApiKeyVO) {
return requestClient.post('/ai/api-key/create', data);
}
// 修改 API 密钥
export function updateApiKey(data: AiModelApiKeyApi.ApiKeyVO) {
return requestClient.put('/ai/api-key/update', data);
}
// 删除 API 密钥
export function deleteApiKey(id: number) {
return requestClient.delete(`/ai/api-key/delete?id=${id}`);
}

View File

@ -0,0 +1,85 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiModelChatRoleApi {
export interface ChatRoleVO {
id: number; // 角色编号
modelId: number; // 模型编号
name: string; // 角色名称
avatar: string; // 角色头像
category: string; // 角色类别
sort: number; // 角色排序
description: string; // 角色描述
systemMessage: string; // 角色设定
welcomeMessage: string; // 角色设定
publicStatus: boolean; // 是否公开
status: number; // 状态
knowledgeIds?: number[]; // 引用的知识库 ID 列表
toolIds?: number[]; // 引用的工具 ID 列表
}
// AI 聊天角色 分页请求 vo
export interface ChatRolePageReqVO {
name?: string; // 角色名称
category?: string; // 角色类别
publicStatus: boolean; // 是否公开
pageNo: number; // 是否公开
pageSize: number; // 是否公开
}
}
// 查询聊天角色分页
export function getChatRolePage(params: PageParam) {
return requestClient.get<PageResult<AiModelChatRoleApi.ChatRoleVO>>(
'/ai/chat-role/page',
{ params },
);
}
// 查询聊天角色详情
export function getChatRole(id: number) {
return requestClient.get<AiModelChatRoleApi.ChatRoleVO>(
`/ai/chat-role/get?id=${id}`,
);
}
// 新增聊天角色
export function createChatRole(data: AiModelChatRoleApi.ChatRoleVO) {
return requestClient.post('/ai/chat-role/create', data);
}
// 修改聊天角色
export function updateChatRole(data: AiModelChatRoleApi.ChatRoleVO) {
return requestClient.put('/ai/chat-role/update', data);
}
// 删除聊天角色
export function deleteChatRole(id: number) {
return requestClient.delete(`/ai/chat-role/delete?id=${id}`);
}
// ======= chat 聊天
// 获取 my role
export function getMyPage(params: AiModelChatRoleApi.ChatRolePageReqVO) {
return requestClient.get('/ai/chat-role/my-page', { params });
}
// 获取角色分类
export function getCategoryList() {
return requestClient.get('/ai/chat-role/category-list');
}
// 创建角色
export function createMy(data: AiModelChatRoleApi.ChatRoleVO) {
return requestClient.post('/ai/chat-role/create-my', data);
}
// 更新角色
export function updateMy(data: AiModelChatRoleApi.ChatRoleVO) {
return requestClient.put('/ai/chat-role/update', data);
}
// 删除角色 my
export function deleteMy(id: number) {
return requestClient.delete(`/ai/chat-role/delete-my?id=${id}`);
}

View File

@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiModelModelApi {
export interface ModelVO {
id: number; // 编号
keyId: number; // API 秘钥编号
name: string; // 模型名字
model: string; // 模型标识
platform: string; // 模型平台
type: number; // 模型类型
sort: number; // 排序
status: number; // 状态
temperature?: number; // 温度参数
maxTokens?: number; // 单条回复的最大 Token 数量
maxContexts?: number; // 上下文的最大 Message 数量
}
}
// 查询模型分页
export function getModelPage(params: PageParam) {
return requestClient.get<PageResult<AiModelModelApi.ModelVO>>(
'/ai/model/page',
{ params },
);
}
// 获得模型列表
export function getModelSimpleList(type?: number) {
return requestClient.get<AiModelModelApi.ModelVO[]>('/ai/model/simple-list', {
params: {
type,
},
});
}
// 查询模型详情
export function getModel(id: number) {
return requestClient.get<AiModelModelApi.ModelVO>(`/ai/model/get?id=${id}`);
}
// 新增模型
export function createModel(data: AiModelModelApi.ModelVO) {
return requestClient.post('/ai/model/create', data);
}
// 修改模型
export function updateModel(data: AiModelModelApi.ModelVO) {
return requestClient.put('/ai/model/update', data);
}
// 删除模型
export function deleteModel(id: number) {
return requestClient.delete(`/ai/model/delete?id=${id}`);
}

View File

@ -0,0 +1,43 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiModelToolApi {
export interface ToolVO {
id: number; // 工具编号
name: string; // 工具名称
description: string; // 工具描述
status: number; // 状态
}
}
// 查询工具分页
export function getToolPage(params: PageParam) {
return requestClient.get<PageResult<AiModelToolApi.ToolVO>>('/ai/tool/page', {
params,
});
}
// 查询工具详情
export function getTool(id: number) {
return requestClient.get<AiModelToolApi.ToolVO>(`/ai/tool/get?id=${id}`);
}
// 新增工具
export function createTool(data: AiModelToolApi.ToolVO) {
return requestClient.post('/ai/tool/create', data);
}
// 修改工具
export function updateTool(data: AiModelToolApi.ToolVO) {
return requestClient.put('/ai/tool/update', data);
}
// 删除工具
export function deleteTool(id: number) {
return requestClient.delete(`/ai/tool/delete?id=${id}`);
}
// 获取工具简单列表
export function getToolSimpleList() {
return requestClient.get<AiModelToolApi.ToolVO[]>('/ai/tool/simple-list');
}

View File

@ -0,0 +1,44 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiMusicApi {
// AI 音乐 VO
export interface MusicVO {
id: number; // 编号
userId: number; // 用户编号
title: string; // 音乐名称
lyric: string; // 歌词
imageUrl: string; // 图片地址
audioUrl: string; // 音频地址
videoUrl: string; // 视频地址
status: number; // 音乐状态
gptDescriptionPrompt: string; // 描述词
prompt: string; // 提示词
platform: string; // 模型平台
model: string; // 模型
generateMode: number; // 生成模式
tags: string; // 音乐风格标签
duration: number; // 音乐时长
publicStatus: boolean; // 是否发布
taskId: string; // 任务id
errorMessage: string; // 错误信息
}
}
// 查询音乐分页
export function getMusicPage(params: PageParam) {
return requestClient.get<PageResult<AiMusicApi.MusicVO>>(`/ai/music/page`, {
params,
});
}
// 更新音乐
export function updateMusic(data: any) {
return requestClient.put('/ai/music/update', data);
}
// 删除音乐
export function deleteMusic(id: number) {
return requestClient.delete(`/ai/music/delete?id=${id}`);
}

View File

@ -0,0 +1,29 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export function getWorkflowPage(params: PageParam) {
return requestClient.get<PageResult<any>>('/ai/workflow/page', {
params,
});
}
export const getWorkflow = (id: number | string) => {
return requestClient.get(`/ai/workflow/get?id=${id}`);
};
export const createWorkflow = (data: any) => {
return requestClient.post('/ai/workflow/create', data);
};
export const updateWorkflow = (data: any) => {
return requestClient.put('/ai/workflow/update', data);
};
export const deleteWorkflow = (id: number | string) => {
return requestClient.delete(`/ai/workflow/delete?id=${id}`);
};
export const testWorkflow = (data: any) => {
return requestClient.post('/ai/workflow/test', data);
};

View File

@ -0,0 +1,95 @@
import type { PageParam, PageResult } from '@vben/request';
import type { AiWriteTypeEnum } from '#/utils/constants';
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiWriteApi {
export interface WriteVO {
type: AiWriteTypeEnum.REPLY | AiWriteTypeEnum.WRITING; // 1:撰写 2:回复
prompt: string; // 写作内容提示 1。撰写 2回复
originalContent: string; // 原文
length: number; // 长度
format: number; // 格式
tone: number; // 语气
language: number; // 语言
userId?: number; // 用户编号
platform?: string; // 平台
model?: string; // 模型
generatedContent?: string; // 生成的内容
errorMessage?: string; // 错误信息
createTime?: Date; // 创建时间
}
export interface AiWritePageReqVO extends PageParam {
userId?: number; // 用户编号
type?: AiWriteTypeEnum; // 写作类型
platform?: string; // 平台
createTime?: [string, string]; // 创建时间
}
export interface AiWriteRespVo {
id: number;
userId: number;
type: number;
platform: string;
model: string;
prompt: string;
generatedContent: string;
originalContent: string;
length: number;
format: number;
tone: number;
language: number;
errorMessage: string;
createTime: string;
}
}
export function writeStream({
data,
onClose,
onMessage,
onError,
ctrl,
}: {
ctrl: AbortController;
data: Partial<AiWriteApi.WriteVO>;
onClose?: (...args: any[]) => void;
onError?: (...args: any[]) => void;
onMessage?: (res: any) => void;
}) {
const token = accessStore.accessToken;
return fetchEventSource(`${apiURL}/ai/write/generate-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 获取写作列表
export function getWritePage(params: any) {
return requestClient.get<PageResult<AiWriteApi.AiWritePageReqVO>>(
`/ai/write/page`,
{ params },
);
}
// 删除音乐
export function deleteWrite(id: number) {
return requestClient.delete(`/ai/write/delete`, { params: { id } });
}

View File

@ -0,0 +1,57 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ProductUnitApi {
/** 产品单位信息 */
export interface ProductUnit {
id: number; // 编号
groupId?: number; // 分组编号
name?: string; // 单位名称
basic?: number; // 基础单位
number?: number; // 单位数量/相对于基础单位
usageType: number; // 用途
}
}
/** 查询产品单位分页 */
export function getProductUnitPage(params: PageParam) {
return requestClient.get<PageResult<ProductUnitApi.ProductUnit>>(
'/basic/product-unit/page',
{ params },
);
}
/** 查询产品单位详情 */
export function getProductUnit(id: number) {
return requestClient.get<ProductUnitApi.ProductUnit>(
`/basic/product-unit/get?id=${id}`,
);
}
/** 新增产品单位 */
export function createProductUnit(data: ProductUnitApi.ProductUnit) {
return requestClient.post('/basic/product-unit/create', data);
}
/** 修改产品单位 */
export function updateProductUnit(data: ProductUnitApi.ProductUnit) {
return requestClient.put('/basic/product-unit/update', data);
}
/** 删除产品单位 */
export function deleteProductUnit(id: number) {
return requestClient.delete(`/basic/product-unit/delete?id=${id}`);
}
/** 批量删除产品单位 */
export function deleteProductUnitListByIds(ids: number[]) {
return requestClient.delete(
`/basic/product-unit/delete-list?ids=${ids.join(',')}`,
);
}
/** 导出产品单位 */
export function exportProductUnit(params: any) {
return requestClient.download('/basic/product-unit/export-excel', params);
}

View File

@ -0,0 +1,61 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ProductUnitGroupApi {
/** 产品单位组信息 */
export interface ProductUnitGroup {
id: number; // 编号
name?: string; // 产品单位组名称
status?: number; // 开启状态
}
}
/** 查询产品单位组分页 */
export function getProductUnitGroupPage(params: PageParam) {
return requestClient.get<PageResult<ProductUnitGroupApi.ProductUnitGroup>>(
'/basic/product-unit-group/page',
{ params },
);
}
/** 查询产品单位组详情 */
export function getProductUnitGroup(id: number) {
return requestClient.get<ProductUnitGroupApi.ProductUnitGroup>(
`/basic/product-unit-group/get?id=${id}`,
);
}
/** 新增产品单位组 */
export function createProductUnitGroup(
data: ProductUnitGroupApi.ProductUnitGroup,
) {
return requestClient.post('/basic/product-unit-group/create', data);
}
/** 修改产品单位组 */
export function updateProductUnitGroup(
data: ProductUnitGroupApi.ProductUnitGroup,
) {
return requestClient.put('/basic/product-unit-group/update', data);
}
/** 删除产品单位组 */
export function deleteProductUnitGroup(id: number) {
return requestClient.delete(`/basic/product-unit-group/delete?id=${id}`);
}
/** 批量删除产品单位组 */
export function deleteProductUnitGroupListByIds(ids: number[]) {
return requestClient.delete(
`/basic/product-unit-group/delete-list?ids=${ids.join(',')}`,
);
}
/** 导出产品单位组 */
export function exportProductUnitGroup(params: any) {
return requestClient.download(
'/basic/product-unit-group/export-excel',
params,
);
}

View File

@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { MarkdownIt } from '@vben/plugins/markmap';
import { useClipboard } from '@vueuse/core';
import { message } from 'ant-design-vue';
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.min.css';
//
const props = defineProps({
content: {
type: String,
required: true,
},
});
const { copy } = useClipboard(); // copy
const contentRef = ref();
const md = new MarkdownIt({
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`;
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`;
} catch {}
}
return ``;
},
});
/** 渲染 markdown */
const renderedMarkdown = computed(() => {
return md.render(props.content);
});
/** 初始化 */
onMounted(async () => {
// copy
contentRef.value.addEventListener('click', (e: any) => {
if (e.target.id === 'copy') {
copy(e.target?.dataset?.copy);
message.success('复制成功!');
}
});
});
</script>
<template>
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
</template>
<style lang="scss">
.markdown-view {
max-width: 100%;
font-family: 'PingFang SC';
font-size: 0.95rem;
font-weight: 400;
line-height: 1.6rem;
color: #3b3e55;
text-align: left;
letter-spacing: 0;
pre {
position: relative;
}
pre code.hljs {
width: auto;
}
code.hljs {
width: auto;
padding-top: 20px;
border-radius: 6px;
@media screen and (min-width: 1536px) {
width: 960px;
}
@media screen and (max-width: 1536px) and (min-width: 1024px) {
width: calc(100vw - 400px - 64px - 32px * 2);
}
@media screen and (max-width: 1024px) and (min-width: 768px) {
width: calc(100vw - 32px * 2);
}
@media screen and (max-width: 768px) {
width: calc(100vw - 16px * 2);
}
}
p,
code.hljs {
margin-bottom: 16px;
}
p {
//margin-bottom: 1rem !important;
margin: 0;
margin-bottom: 3px;
}
/* 标题通用格式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 24px 0 8px;
font-weight: 600;
color: #3b3e55;
}
h1 {
font-size: 22px;
line-height: 32px;
}
h2 {
font-size: 20px;
line-height: 30px;
}
h3 {
font-size: 18px;
line-height: 28px;
}
h4 {
font-size: 16px;
line-height: 26px;
}
h5 {
font-size: 16px;
line-height: 24px;
}
h6 {
font-size: 16px;
line-height: 24px;
}
/* 列表(有序,无序) */
ul,
ol {
padding: 0;
margin: 0 0 8px;
font-size: 16px;
line-height: 24px;
color: #3b3e55; // var(--color-CG600);
}
li {
margin: 4px 0 0 20px;
margin-bottom: 1rem;
}
ol > li {
margin-bottom: 1rem;
list-style-type: decimal;
// ,
// &:nth-child(n + 10) {
// margin-left: 30px;
// }
// &:nth-child(n + 100) {
// margin-left: 30px;
// }
}
ul > li {
margin-right: 11px;
margin-bottom: 1rem;
font-size: 16px;
line-height: 24px;
color: #3b3e55; // var(--color-G900);
list-style-type: disc;
}
ol ul,
ol ul > li,
ul ul,
ul ul li {
margin-bottom: 1rem;
margin-left: 6px;
// list-style: circle;
font-size: 16px;
list-style: none;
}
ul ul ul,
ul ul ul li,
ol ol,
ol ol > li,
ol ul ul,
ol ul ul > li,
ul ol,
ul ol > li {
list-style: square;
}
}
</style>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Item } from './ui/typeing';
import { onMounted, onUnmounted, ref } from 'vue';
import { Tinyflow as TinyflowNative } from './ui/index';
import './ui/index.css';
const props = defineProps<{
className?: string;
data?: Record<string, any>;
provider?: {
internal?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
llm?: () => Item[] | Promise<Item[]>;
};
style?: Record<string, string>;
}>();
const divRef = ref<HTMLDivElement | null>(null);
let tinyflow: null | TinyflowNative = null;
// provider
const defaultProvider = {
llm: () => [] as Item[],
knowledge: () => [] as Item[],
internal: () => [] as Item[],
};
onMounted(() => {
if (divRef.value) {
// provider props.provider
const mergedProvider = {
...defaultProvider,
...props.provider,
};
tinyflow = new TinyflowNative({
element: divRef.value as Element,
data: props.data || {},
provider: mergedProvider,
});
}
});
onUnmounted(() => {
if (tinyflow) {
tinyflow.destroy();
tinyflow = null;
}
});
const getData = () => {
if (tinyflow) {
return tinyflow.getData();
}
console.warn('Tinyflow instance is not initialized');
return null;
};
defineExpose({
getData,
});
</script>
<template>
<div
ref="divRef"
class="tinyflow"
:class="[className]"
:style="style"
style="height: 100%"
></div>
</template>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
export declare type Item = {
children?: Item[];
label: string;
value: number | string;
};
export type Position = {
x: number;
y: number;
};
export type Viewport = {
x: number;
y: number;
zoom: number;
};
export type Node = {
data?: Record<string, any>;
draggable?: boolean;
height?: number;
id: string;
position: Position;
selected?: boolean;
type?: string;
width?: number;
};
export type Edge = {
animated?: boolean;
id: string;
label?: string;
source: string;
target: string;
type?: string;
};
export type TinyflowData = Partial<{
edges: Edge[];
nodes: Node[];
viewport: Viewport;
}>;
export declare type TinyflowOptions = {
data?: TinyflowData;
element: Element | string;
provider?: {
internal?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
llm?: () => Item[] | Promise<Item[]>;
};
};
export declare class Tinyflow {
private _init;
private _setOptions;
private options;
private rootEl;
private svelteFlowInstance;
constructor(options: TinyflowOptions);
destroy(): void;
getData(): {
edges: Edge[];
nodes: Node[];
viewport: Viewport;
};
getOptions(): TinyflowOptions;
setData(data: TinyflowData): void;
}
export {};

View File

@ -0,0 +1,113 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/ai',
name: 'Ai',
meta: {
title: 'Ai',
hideInMenu: true,
},
children: [
{
path: 'image/square',
component: () => import('#/views/ai/image/square/index.vue'),
name: 'AiImageSquare',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '绘图作品',
activePath: '/ai/image',
},
},
{
path: 'knowledge/document',
component: () => import('#/views/ai/knowledge/document/index.vue'),
name: 'AiKnowledgeDocument',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '知识库文档',
activePath: '/ai/knowledge',
},
},
{
path: 'knowledge/document/create',
component: () => import('#/views/ai/knowledge/document/form/index.vue'),
name: 'AiKnowledgeDocumentCreate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '创建文档',
activePath: '/ai/knowledge',
},
},
{
path: 'knowledge/document/update',
component: () => import('#/views/ai/knowledge/document/form/index.vue'),
name: 'AiKnowledgeDocumentUpdate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '修改文档',
activePath: '/ai/knowledge',
},
},
{
path: 'knowledge/retrieval',
component: () =>
import('#/views/ai/knowledge/knowledge/retrieval/index.vue'),
name: 'AiKnowledgeRetrieval',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '文档召回测试',
activePath: '/ai/knowledge',
},
},
{
path: 'knowledge/segment',
component: () => import('#/views/ai/knowledge/segment/index.vue'),
name: 'AiKnowledgeSegment',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '知识库分段',
activePath: '/ai/knowledge',
},
},
{
path: 'console/workflow/create',
component: () => import('#/views/ai/workflow/form/index.vue'),
name: 'AiWorkflowCreate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '设计 AI 工作流',
activePath: '/ai/workflow',
},
},
{
path: 'console/workflow/:type/:id',
component: () => import('#/views/ai/workflow/form/index.vue'),
name: 'AiWorkflowUpdate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '设计 AI 工作流',
activePath: '/ai/workflow',
},
},
],
},
];
export default routes;

View File

@ -5,6 +5,244 @@
*
*/
/**
* AI
*/
export const AiPlatformEnum = {
TONG_YI: 'TongYi', // 阿里
YI_YAN: 'YiYan', // 百度
DEEP_SEEK: 'DeepSeek', // DeepSeek
ZHI_PU: 'ZhiPu', // 智谱 AI
XING_HUO: 'XingHuo', // 讯飞
SiliconFlow: 'SiliconFlow', // 硅基流动
OPENAI: 'OpenAI',
Ollama: 'Ollama',
STABLE_DIFFUSION: 'StableDiffusion', // Stability AI
MIDJOURNEY: 'Midjourney', // Midjourney
SUNO: 'Suno', // Suno AI
};
export const AiModelTypeEnum = {
CHAT: 1, // 聊天
IMAGE: 2, // 图像
VOICE: 3, // 音频
VIDEO: 4, // 视频
EMBEDDING: 5, // 向量
RERANK: 6, // 重排
};
export interface ImageModelVO {
key: string;
name: string;
image?: string;
}
export const OtherPlatformEnum: ImageModelVO[] = [
{
key: AiPlatformEnum.TONG_YI,
name: '通义万相',
},
{
key: AiPlatformEnum.YI_YAN,
name: '百度千帆',
},
{
key: AiPlatformEnum.ZHI_PU,
name: '智谱 AI',
},
{
key: AiPlatformEnum.SiliconFlow,
name: '硅基流动',
},
];
/**
* AI
*/
export const AiImageStatusEnum = {
IN_PROGRESS: 10, // 进行中
SUCCESS: 20, // 已完成
FAIL: 30, // 已失败
};
/**
* AI
*/
export const AiMusicStatusEnum = {
IN_PROGRESS: 10, // 进行中
SUCCESS: 20, // 已完成
FAIL: 30, // 已失败
};
/**
* AI
*/
export enum AiWriteTypeEnum {
WRITING = 1, // 撰写
REPLY, // 回复
}
// ========== 【图片 UI】相关的枚举 ==========
export const ImageHotWords = [
'中国旗袍',
'古装美女',
'卡通头像',
'机甲战士',
'童话小屋',
'中国长城',
]; // 图片热词
export const ImageHotEnglishWords = [
'Chinese Cheongsam',
'Ancient Beauty',
'Cartoon Avatar',
'Mech Warrior',
'Fairy Tale Cottage',
'The Great Wall of China',
]; // 图片热词(英文)
export const StableDiffusionSamplers: ImageModelVO[] = [
{
key: 'DDIM',
name: 'DDIM',
},
{
key: 'DDPM',
name: 'DDPM',
},
{
key: 'K_DPMPP_2M',
name: 'K_DPMPP_2M',
},
{
key: 'K_DPMPP_2S_ANCESTRAL',
name: 'K_DPMPP_2S_ANCESTRAL',
},
{
key: 'K_DPM_2',
name: 'K_DPM_2',
},
{
key: 'K_DPM_2_ANCESTRAL',
name: 'K_DPM_2_ANCESTRAL',
},
{
key: 'K_EULER',
name: 'K_EULER',
},
{
key: 'K_EULER_ANCESTRAL',
name: 'K_EULER_ANCESTRAL',
},
{
key: 'K_HEUN',
name: 'K_HEUN',
},
{
key: 'K_LMS',
name: 'K_LMS',
},
];
export const StableDiffusionStylePresets: ImageModelVO[] = [
{
key: '3d-model',
name: '3d-model',
},
{
key: 'analog-film',
name: 'analog-film',
},
{
key: 'anime',
name: 'anime',
},
{
key: 'cinematic',
name: 'cinematic',
},
{
key: 'comic-book',
name: 'comic-book',
},
{
key: 'digital-art',
name: 'digital-art',
},
{
key: 'enhance',
name: 'enhance',
},
{
key: 'fantasy-art',
name: 'fantasy-art',
},
{
key: 'isometric',
name: 'isometric',
},
{
key: 'line-art',
name: 'line-art',
},
{
key: 'low-poly',
name: 'low-poly',
},
{
key: 'modeling-compound',
name: 'modeling-compound',
},
// neon-punk origami photographic pixel-art tile-texture
{
key: 'neon-punk',
name: 'neon-punk',
},
{
key: 'origami',
name: 'origami',
},
{
key: 'photographic',
name: 'photographic',
},
{
key: 'pixel-art',
name: 'pixel-art',
},
{
key: 'tile-texture',
name: 'tile-texture',
},
];
export const StableDiffusionClipGuidancePresets: ImageModelVO[] = [
{
key: 'NONE',
name: 'NONE',
},
{
key: 'FAST_BLUE',
name: 'FAST_BLUE',
},
{
key: 'FAST_GREEN',
name: 'FAST_GREEN',
},
{
key: 'SIMPLE',
name: 'SIMPLE',
},
{
key: 'SLOW',
name: 'SLOW',
},
{
key: 'SLOWER',
name: 'SLOWER',
},
{
key: 'SLOWEST',
name: 'SLOWEST',
},
];
// ========== COMMON 模块 ==========
// 全局通用状态枚举
export const CommonStatusEnum = {
@ -92,7 +330,136 @@ export const InfraApiErrorLogProcessStatusEnum = {
DONE: 1, // 已处理
IGNORE: 2, // 已忽略
};
export interface ImageSizeVO {
key: string;
name?: string;
style: string;
width: string;
height: string;
}
export const Dall3SizeList: ImageSizeVO[] = [
{
key: '1024x1024',
name: '1:1',
width: '1024',
height: '1024',
style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
},
{
key: '1024x1792',
name: '3:5',
width: '1024',
height: '1792',
style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
},
{
key: '1792x1024',
name: '5:3',
width: '1792',
height: '1024',
style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
},
];
export const Dall3Models: ImageModelVO[] = [
{
key: 'dall-e-3',
name: 'DALL·E 3',
image: `/static/dall2.jpg`,
},
{
key: 'dall-e-2',
name: 'DALL·E 2',
image: `/static/dall3.jpg`,
},
];
export const Dall3StyleList: ImageModelVO[] = [
{
key: 'vivid',
name: '清晰',
image: `/static/qingxi.jpg`,
},
{
key: 'natural',
name: '自然',
image: `/static/ziran.jpg`,
},
];
export const MidjourneyModels: ImageModelVO[] = [
{
key: 'midjourney',
name: 'MJ',
image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png',
},
{
key: 'niji',
name: 'NIJI',
image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png',
},
];
export const MidjourneyVersions = [
{
value: '6.0',
label: 'v6.0',
},
{
value: '5.2',
label: 'v5.2',
},
{
value: '5.1',
label: 'v5.1',
},
{
value: '5.0',
label: 'v5.0',
},
{
value: '4.0',
label: 'v4.0',
},
];
export const NijiVersionList = [
{
value: '5',
label: 'v5',
},
];
export const MidjourneySizeList: ImageSizeVO[] = [
{
key: '1:1',
width: '1',
height: '1',
style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
},
{
key: '3:4',
width: '3',
height: '4',
style: 'width: 30px; height: 40px;background-color: #dcdcdc;',
},
{
key: '4:3',
width: '4',
height: '3',
style: 'width: 40px; height: 30px;background-color: #dcdcdc;',
},
{
key: '9:16',
width: '9',
height: '16',
style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
},
{
key: '16:9',
width: '16',
height: '9',
style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
},
];
// ========== PAY 模块 ==========
/**
*
@ -743,3 +1110,81 @@ export enum ProcessVariableEnum {
*/
START_USER_ID = 'PROCESS_START_USER_ID',
}
// ========== 【写作 UI】相关的枚举 ==========
/** 写作点击示例时的数据 */
export const WriteExample = {
write: {
prompt: 'vue',
data: 'Vue.js 是一种用于构建用户界面的渐进式 JavaScript 框架。它的核心库只关注视图层,易于上手,同时也便于与其他库或已有项目整合。\n\nVue.js 的特点包括:\n- 响应式的数据绑定Vue.js 会自动将数据与 DOM 同步,使得状态管理变得更加简单。\n- 组件化Vue.js 允许开发者通过小型、独立和通常可复用的组件构建大型应用。\n- 虚拟 DOMVue.js 使用虚拟 DOM 实现快速渲染,提高了性能。\n\n在 Vue.js 中,一个典型的应用结构可能包括:\n1. 根实例:每个 Vue 应用都需要一个根实例作为入口点。\n2. 组件系统:可以创建自定义的可复用组件。\n3. 指令:特殊的带有前缀 v- 的属性,为 DOM 元素提供特殊的行为。\n4. 插值:用于文本内容,将数据动态地插入到 HTML。\n5. 计算属性和侦听器:用于处理数据的复杂逻辑和响应数据变化。\n6. 条件渲染:根据条件决定元素的渲染。\n7. 列表渲染:用于显示列表数据。\n8. 事件处理:响应用户交互。\n9. 表单输入绑定:处理表单输入和验证。\n10. 组件生命周期钩子:在组件的不同阶段执行特定的函数。\n\nVue.js 还提供了官方的路由器 Vue Router 和状态管理库 Vuex以支持构建复杂的单页应用SPA。\n\n在开发过程中开发者通常会使用 Vue CLI这是一个强大的命令行工具用于快速生成 Vue 项目脚手架,集成了诸如 Babel、Webpack 等现代前端工具,以及热重载、代码检测等开发体验优化功能。\n\nVue.js 的生态系统还包括大量的第三方库和插件,如 VuetifyUI 组件库、Vue Test Utils测试工具这些都极大地丰富了 Vue.js 的开发生态。\n\n总的来说Vue.js 是一个灵活、高效的前端框架,适合从小型项目到大型企业级应用的开发。它的易用性、灵活性和强大的社区支持使其成为许多开发者的首选框架之一。',
},
reply: {
originalContent: '领导,我想请假',
prompt: '不批',
data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务请及时与我联系。\n\n祝工作顺利。\n\n谢谢。',
},
};
// ========== 【思维导图 UI】相关的枚举 ==========
/** 思维导图已有内容生成示例 */
export const MindMapContentExample = `# Java 技术栈
##
### Java SE
### Java EE
##
### Spring
#### Spring Boot
#### Spring MVC
#### Spring Data
### Hibernate
### MyBatis
##
### Maven
### Gradle
##
### Git
### SVN
##
### JUnit
### Mockito
### Selenium
##
### Tomcat
### Jetty
### WildFly
##
### MySQL
### PostgreSQL
### Oracle
### MongoDB
##
### Kafka
### RabbitMQ
### ActiveMQ
##
### Spring Cloud
### Dubbo
##
### Docker
### Kubernetes
##
### AWS
### Azure
### Google Cloud
##
### IntelliJ IDEA
### Eclipse
### Visual Studio Code`;

View File

@ -143,10 +143,10 @@ export const getBoolDictOptions = (dictType: string) => {
enum DICT_TYPE {
AI_GENERATE_MODE = 'ai_generate_mode', // AI 生成模式
AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态
AI_MODEL_TYPE = 'ai_model_type', // AI 模型类型
AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态
// ========== AI - 人工智能模块 ==========
AI_PLATFORM = 'ai_platform', // AI 平台
AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度

View File

@ -0,0 +1,98 @@
const download0 = (data: Blob, fileName: string, mineType: string) => {
// 创建 blob
const blob = new Blob([data], { type: mineType });
// 创建 href 超链接,点击进行下载
window.URL = window.URL || window.webkitURL;
const href = URL.createObjectURL(blob);
const downA = document.createElement('a');
downA.href = href;
downA.download = fileName;
downA.click();
// 销毁超连接
window.URL.revokeObjectURL(href);
};
export const download = {
// 下载 Excel 方法
excel: (data: Blob, fileName: string) => {
download0(data, fileName, 'application/vnd.ms-excel');
},
// 下载 Word 方法
word: (data: Blob, fileName: string) => {
download0(data, fileName, 'application/msword');
},
// 下载 Zip 方法
zip: (data: Blob, fileName: string) => {
download0(data, fileName, 'application/zip');
},
// 下载 Html 方法
html: (data: Blob, fileName: string) => {
download0(data, fileName, 'text/html');
},
// 下载 Markdown 方法
markdown: (data: Blob, fileName: string) => {
download0(data, fileName, 'text/markdown');
},
// 下载 Json 方法
json: (data: Blob, fileName: string) => {
download0(data, fileName, 'application/json');
},
// 下载图片(允许跨域)
image: ({
url,
canvasWidth,
canvasHeight,
drawWithImageSize = true,
}: {
canvasHeight?: number; // 指定画布高度
canvasWidth?: number; // 指定画布宽度
drawWithImageSize?: boolean; // 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的
url: string;
}) => {
const image = new Image();
// image.setAttribute('crossOrigin', 'anonymous')
image.src = url;
image.addEventListener('load', () => {
const canvas = document.createElement('canvas');
canvas.width = canvasWidth || image.width;
canvas.height = canvasHeight || image.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx?.clearRect(0, 0, canvas.width, canvas.height);
if (drawWithImageSize) {
ctx.drawImage(image, 0, 0, image.width, image.height);
} else {
ctx.drawImage(image, 0, 0);
}
const url = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = 'image.png';
a.click();
});
},
base64ToFile: (base64: any, fileName: string) => {
// 将base64按照 , 进行分割 将前缀 与后续内容分隔开
const data = base64.split(',');
// 利用正则表达式 从前缀中获取图片的类型信息image/png、image/jpeg、image/webp等
const type = data[0].match(/:(.*?);/)[1];
// 从图片的类型信息中 获取具体的文件格式后缀png、jpeg、webp
const suffix = type.split('/')[1];
// 使用atob()对base64数据进行解码 结果是一个文件数据流 以字符串的格式输出
const bstr = window.atob(data[1]);
// 获取解码结果字符串的长度
let n = bstr.length;
// 根据解码结果字符串的长度创建一个等长的整形数字数组
// 但在创建时 所有元素初始值都为 0
const u8arr = new Uint8Array(n);
// 将整形数组的每个元素填充为解码结果字符串对应位置字符的UTF-16 编码单元
while (n--) {
// charCodeAt():获取给定索引处字符对应的 UTF-16 代码单元
u8arr[n] = bstr.charCodeAt(n);
}
// 将File文件对象返回给方法的调用者
return new File([u8arr], `${fileName}.${suffix}`, {
type,
});
},
};

View File

@ -0,0 +1,124 @@
import { formatDate } from '@vben/utils';
/**
* `几秒前``几分钟前``几小时前``几天前`
* @param param new Date()
* @param format
* @description param 10 10 * 1000
* @description param 1 60 * 1000
* @description param 1 60 * 60 * 1000
* @description param 2460 * 60 * 24 * 1000
* @description param 3 60 * 60* 24 * 1000 * 3
* @returns
*/
export function formatPast(
param: Date | string,
format = 'YYYY-MM-DD HH:mm:ss',
): string {
// 传入格式处理、存储转换值
let s: number, t: any;
// 获取js 时间戳
let time: number = Date.now();
// 是否是对象
typeof param === 'string' || typeof param === 'object'
? (t = new Date(param).getTime())
: (t = param);
// 当前时间戳 - 传入时间戳
time = Number.parseInt(`${time - t}`);
if (time < 10_000) {
// 10秒内
return '刚刚';
} else if (time < 60_000 && time >= 10_000) {
// 超过10秒少于1分钟内
s = Math.floor(time / 1000);
return `${s}秒前`;
} else if (time < 3_600_000 && time >= 60_000) {
// 超过1分钟少于1小时
s = Math.floor(time / 60_000);
return `${s}分钟前`;
} else if (time < 86_400_000 && time >= 3_600_000) {
// 超过1小时少于24小时
s = Math.floor(time / 3_600_000);
return `${s}小时前`;
} else if (time < 259_200_000 && time >= 86_400_000) {
// 超过1天少于3天内
s = Math.floor(time / 86_400_000);
return `${s}天前`;
} else {
// 超过3天
const date =
typeof param === 'string' || typeof param === 'object'
? new Date(param)
: param;
return formatDate(date, format);
}
}
/**
* xx
*
* @param ms
* @returns {string}
*/
// TODO @xingyu这个要融合到哪里去 date 么?
export function formatPast2(ms: number): string {
// 定义时间单位常量,便于维护
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// 计算各时间单位
const day = Math.floor(ms / DAY);
const hour = Math.floor((ms % DAY) / HOUR);
const minute = Math.floor((ms % HOUR) / MINUTE);
const second = Math.floor((ms % MINUTE) / SECOND);
// 根据时间长短返回不同格式
if (day > 0) {
return `${day}${hour} 小时 ${minute} 分钟`;
}
if (hour > 0) {
return `${hour} 小时 ${minute} 分钟`;
}
if (minute > 0) {
return `${minute} 分钟`;
}
return second > 0 ? `${second}` : `${0}`;
}
/**
* @param {Date | number | string} time
* @param {string} fmt yyyy-MM-ddyyyy-MM-dd HH:mm:ss
*/
export function formatTime(time: Date | number | string, fmt: string) {
if (time) {
const date = new Date(time);
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
S: date.getMilliseconds(),
};
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
`${date.getFullYear()}`.slice(4 - RegExp.$1.length),
);
}
for (const k in o) {
if (new RegExp(`(${k})`).test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length === 1 ? o[k] : `00${o[k]}`.slice(`${o[k]}`.length),
);
}
}
return fmt;
} else {
return '';
}
}

View File

@ -3,3 +3,5 @@ export * from './dict';
export * from './formCreate';
export * from './rangePickerProps';
export * from './routerHelper';
export * from './upload';
export * from './utils';

View File

@ -0,0 +1,67 @@
/**
* accept
*
* @param supportedFileTypes ['PDF', 'DOC', 'DOCX']
* @returns accept
*/
export const generateAcceptedFileTypes = (
supportedFileTypes: string[],
): string => {
const allowedExtensions = supportedFileTypes.map((ext) => ext.toLowerCase());
const mimeTypes: string[] = [];
// 添加常见的 MIME 类型映射
if (allowedExtensions.includes('txt')) {
mimeTypes.push('text/plain');
}
if (allowedExtensions.includes('pdf')) {
mimeTypes.push('application/pdf');
}
if (allowedExtensions.includes('html') || allowedExtensions.includes('htm')) {
mimeTypes.push('text/html');
}
if (allowedExtensions.includes('csv')) {
mimeTypes.push('text/csv');
}
if (allowedExtensions.includes('xlsx') || allowedExtensions.includes('xls')) {
mimeTypes.push(
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
}
if (allowedExtensions.includes('docx') || allowedExtensions.includes('doc')) {
mimeTypes.push(
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
}
if (allowedExtensions.includes('pptx') || allowedExtensions.includes('ppt')) {
mimeTypes.push(
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
);
}
if (allowedExtensions.includes('xml')) {
mimeTypes.push('application/xml', 'text/xml');
}
if (
allowedExtensions.includes('md') ||
allowedExtensions.includes('markdown')
) {
mimeTypes.push('text/markdown');
}
if (allowedExtensions.includes('epub')) {
mimeTypes.push('application/epub+zip');
}
if (allowedExtensions.includes('eml')) {
mimeTypes.push('message/rfc822');
}
if (allowedExtensions.includes('msg')) {
mimeTypes.push('application/vnd.ms-outlook');
}
// 添加文件扩展名
const extensions = allowedExtensions.map((ext) => `.${ext}`);
return [...mimeTypes, ...extensions].join(',');
};

View File

@ -0,0 +1,13 @@
/**
* Created by
*
* AI
*
* src/utils/common-utils.ts
* AI /views/ai/utils/common-utils.ts
*/
/** 判断字符串是否包含中文 */
export const hasChinese = (str: string) => {
return /[\u4E00-\u9FA5]/.test(str);
};

View File

@ -0,0 +1,442 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import { h, onMounted, ref, toRefs, watch } from 'vue';
import { confirm, prompt, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, Empty, Input, Layout, message } from 'ant-design-vue';
import {
createChatConversationMy,
deleteChatConversationMy,
deleteChatConversationMyByUnpinned,
getChatConversationMyList,
updateChatConversationMy,
} from '#/api/ai/chat/conversation';
import RoleRepository from '../role/RoleRepository.vue';
//
// props
const props = defineProps({
activeId: {
type: [Number, null] as PropType<null | number>,
default: null,
},
});
/** 新建对话 */
//
const emits = defineEmits([
'onConversationCreate',
'onConversationClick',
'onConversationClear',
'onConversationDelete',
]);
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: RoleRepository,
});
//
const searchName = ref<string>(''); //
const activeConversationId = ref<null | number>(null); // null
const hoverConversationId = ref<null | number>(null); //
const conversationList = ref([] as AiChatConversationApi.ChatConversationVO[]); //
const conversationMap = ref<any>({}); // ()
const loading = ref<boolean>(false); //
const loadingTime = ref<any>();
/** 搜索对话 */
const searchConversation = async () => {
//
if (searchName.value.trim().length === 0) {
conversationMap.value = await getConversationGroupByCreateTime(
conversationList.value,
);
} else {
//
const filterValues = conversationList.value.filter((item) => {
return item.title.includes(searchName.value.trim());
});
conversationMap.value =
await getConversationGroupByCreateTime(filterValues);
}
};
/** 点击对话 */
const handleConversationClick = async (id: number) => {
//
const filterConversation = conversationList.value.find((item) => {
return item.id === id;
});
// onConversationClick
// noinspection JSVoidFunctionReturnValueUsed
const success = emits('onConversationClick', filterConversation);
//
if (success) {
activeConversationId.value = id;
}
};
/** 获取对话列表 */
const getChatConversationList = async () => {
try {
//
loadingTime.value = setTimeout(() => {
loading.value = true;
}, 50);
// 1.1
conversationList.value = await getChatConversationMyList();
// 1.2
conversationList.value.sort((a, b) => {
return Number(b.createTime) - Number(a.createTime);
});
// 1.3
if (conversationList.value.length === 0) {
activeConversationId.value = null;
conversationMap.value = {};
return;
}
// 2. (30 )
conversationMap.value = await getConversationGroupByCreateTime(
conversationList.value,
);
} finally {
//
if (loadingTime.value) {
clearTimeout(loadingTime.value);
}
//
loading.value = false;
}
};
/** 按照 creteTime 创建时间,进行分组 */
const getConversationGroupByCreateTime = async (
list: AiChatConversationApi.ChatConversationVO[],
) => {
// (30)
// noinspection NonAsciiCharacters
const groupMap: any = {
置顶: [],
今天: [],
一天前: [],
三天前: [],
七天前: [],
三十天前: [],
};
//
const now = Date.now();
//
const oneDay = 24 * 60 * 60 * 1000;
const threeDays = 3 * oneDay;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
for (const conversation of list) {
//
if (conversation.pinned) {
groupMap['置顶'].push(conversation);
continue;
}
//
const diff = now - Number(conversation.createTime);
//
if (diff < oneDay) {
groupMap['今天'].push(conversation);
} else if (diff < threeDays) {
groupMap['一天前'].push(conversation);
} else if (diff < sevenDays) {
groupMap['三天前'].push(conversation);
} else if (diff < thirtyDays) {
groupMap['七天前'].push(conversation);
} else {
groupMap['三十天前'].push(conversation);
}
}
return groupMap;
};
const createConversation = async () => {
// 1.
const conversationId = await createChatConversationMy(
{} as unknown as AiChatConversationApi.ChatConversationVO,
);
// 2.
await getChatConversationList();
// 3.
await handleConversationClick(conversationId);
// 4.
emits('onConversationCreate');
};
/** 修改对话的标题 */
const updateConversationTitle = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
// 1.
prompt({
async beforeClose(scope) {
if (scope.isConfirm) {
if (scope.value) {
try {
// 2.
await updateChatConversationMy({
id: conversation.id,
title: scope.value,
} as AiChatConversationApi.ChatConversationVO);
message.success('重命名成功');
// 3.
await getChatConversationList();
// 4.
const filterConversationList = conversationList.value.filter(
(item) => {
return item.id === conversation.id;
},
);
if (
filterConversationList.length > 0 &&
filterConversationList[0] && // tip
activeConversationId.value === filterConversationList[0].id
) {
emits('onConversationClick', filterConversationList[0]);
}
} catch {
return false;
}
} else {
message.error('请输入标题');
return false;
}
}
},
component: () => {
return h(Input, {
placeholder: '请输入标题',
allowClear: true,
defaultValue: conversation.title,
rules: [{ required: true, message: '请输入标题' }],
});
},
content: '请输入标题',
title: '修改标题',
modelPropName: 'value',
});
};
/** 删除聊天对话 */
const deleteChatConversation = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
try {
//
await confirm(`是否确认删除对话 - ${conversation.title}?`);
//
await deleteChatConversationMy(conversation.id);
message.success('对话已删除');
//
await getChatConversationList();
//
emits('onConversationDelete', conversation);
} catch {}
};
const handleClearConversation = async () => {
try {
await confirm('确认后对话会全部清空,置顶的对话除外。');
await deleteChatConversationMyByUnpinned();
message.success('操作成功!');
//
activeConversationId.value = null;
//
await getChatConversationList();
//
emits('onConversationClear');
} catch {}
};
/** 对话置顶 */
const handleTop = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
//
conversation.pinned = !conversation.pinned;
await updateChatConversationMy(conversation);
//
await getChatConversationList();
};
// ============ ============
/** 角色仓库抽屉 */
const handleRoleRepository = async () => {
drawerApi.open();
};
/** 监听选中的对话 */
const { activeId } = toRefs(props);
watch(activeId, async (newValue) => {
activeConversationId.value = newValue;
});
// public
defineExpose({ createConversation });
/** 初始化 */
onMounted(async () => {
//
await getChatConversationList();
//
if (props.activeId) {
activeConversationId.value = props.activeId;
} else {
//
if (conversationList.value.length > 0 && conversationList.value[0]) {
activeConversationId.value = conversationList.value[0].id;
// onConversationClick
await emits('onConversationClick', conversationList.value[0]);
}
}
});
</script>
<template>
<Layout.Sider
width="260px"
class="conversation-container relative flex h-full flex-col justify-between overflow-hidden bg-[hsl(var(--primary-foreground))!important] p-[10px_10px_0]"
>
<Drawer />
<!-- 左顶部对话 -->
<div class="flex h-full flex-col">
<Button
class="btn-new-conversation h-[38px] w-full"
type="primary"
@click="createConversation"
>
<IconifyIcon icon="ep:plus" class="mr-[5px]" />
新建对话
</Button>
<Input
v-model:value="searchName"
size="large"
class="search-input mt-[20px]"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #prefix>
<IconifyIcon icon="ep:search" />
</template>
</Input>
<!-- 左中间对话列表 -->
<div class="conversation-list mt-[10px] flex-1 overflow-auto">
<!-- 情况一加载中 -->
<Empty v-if="loading" description="." v-loading="loading" />
<!-- 情况二按照 group 分组 -->
<div
v-for="conversationKey in Object.keys(conversationMap)"
:key="conversationKey"
class=""
>
<div
v-if="conversationMap[conversationKey].length > 0"
class="conversation-item classify-title pt-[10px]"
>
<b class="mx-[4px]">
{{ conversationKey }}
</b>
</div>
<div
v-for="conversation in conversationMap[conversationKey]"
:key="conversation.id"
@click="handleConversationClick(conversation.id)"
@mouseover="hoverConversationId = conversation.id"
@mouseout="hoverConversationId = null"
class="conversation-item mt-[5px]"
>
<div
class="conversation flex cursor-pointer flex-row items-center justify-between rounded-[5px] px-[5px] leading-[30px]"
:class="[
conversation.id === activeConversationId ? 'bg-[#e6e6e6]' : '',
]"
>
<div class="title-wrapper flex items-center">
<img
class="avatar h-[25px] w-[25px] rounded-[5px]"
:src="conversation.roleAvatar ?? '/static/gpt.svg'"
/>
<span
class="title text-black/77 max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap px-[10px] py-[2px] text-[14px] font-normal"
>
{{ conversation.title }}
</span>
</div>
<div
v-show="hoverConversationId === conversation.id"
class="button-wrapper relative right-[2px] flex items-center text-[#606266]"
>
<Button
class="btn mr-0 px-[5px]"
type="link"
@click.stop="handleTop(conversation)"
>
<span
v-if="!conversation.pinned"
class="icon-[ant-design--arrow-up-outlined]"
></span>
<span
v-if="conversation.pinned"
class="icon-[ant-design--arrow-down-outlined]"
></span>
</Button>
<Button
class="btn mr-0 px-[5px]"
type="link"
@click.stop="updateConversationTitle(conversation)"
>
<IconifyIcon icon="ep:edit" />
</Button>
<Button
class="btn mr-0 px-[5px]"
type="link"
@click.stop="deleteChatConversation(conversation)"
>
<IconifyIcon icon="ep:delete" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 底部占位 -->
<div class="h-[50px] w-full"></div>
</div>
<!-- 左底部工具栏 -->
<div
class="tool-box absolute bottom-0 left-0 right-0 flex items-center justify-between bg-[#f4f4f4] px-[20px] leading-[35px] text-[var(--el-text-color)] shadow-[0_0_1px_1px_rgba(228,228,228,0.8)]"
>
<div
class="flex cursor-pointer items-center text-[#606266]"
@click="handleRoleRepository"
>
<IconifyIcon icon="ep:user" />
<span class="ml-[5px]">角色仓库</span>
</div>
<div
class="flex cursor-pointer items-center text-[#606266]"
@click="handleClearConversation"
>
<IconifyIcon icon="ep:delete" />
<span class="ml-[5px]">清空未置顶对话</span>
</div>
</div>
</Layout.Sider>
</template>

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
getChatConversationMy,
updateChatConversationMy,
} from '#/api/ai/chat/conversation';
import { $t } from '#/locales';
import { useFormSchema } from '../../data';
const emit = defineEmits(['success']);
const formData = ref<AiChatConversationApi.ChatConversationVO>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as AiChatConversationApi.ChatConversationVO;
try {
await updateChatConversationMy(data);
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiChatConversationApi.ChatConversationVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getChatConversationMy(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" title="设定">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Tooltip } from 'ant-design-vue';
const props = defineProps<{
segments: {
content: string;
documentId: number;
documentName: string;
id: number;
}[];
}>();
const document = ref<null | {
id: number;
segments: {
content: string;
id: number;
}[];
title: string;
}>(null); //
const dialogVisible = ref(false); //
const documentRef = ref<HTMLElement>(); // Ref
/** 按照 document 聚合 segments */
const documentList = computed(() => {
if (!props.segments) return [];
const docMap = new Map();
props.segments.forEach((segment) => {
if (!docMap.has(segment.documentId)) {
docMap.set(segment.documentId, {
id: segment.documentId,
title: segment.documentName,
segments: [],
});
}
docMap.get(segment.documentId).segments.push({
id: segment.id,
content: segment.content,
});
});
return [...docMap.values()];
});
/** 点击 document 处理 */
const handleClick = (doc: any) => {
document.value = doc;
dialogVisible.value = true;
};
</script>
<template>
<!-- 知识引用列表 -->
<div
v-if="segments && segments.length > 0"
class="mt-[10px] rounded-[8px] bg-[#f5f5f5] p-[10px]"
>
<div class="text-14px mb-8px flex items-center text-[#666]">
<IconifyIcon icon="ep:document" class="mr-[5px]" /> 知识引用
</div>
<div class="flex flex-wrap gap-[8px]">
<div
v-for="(doc, index) in documentList"
:key="index"
class="cursor-pointer rounded-[6px] bg-white p-[8px] px-[12px] transition-all hover:bg-[#e6f4ff]"
@click="handleClick(doc)"
>
<div class="mb-[4px] text-[14px] text-[#333]">
{{ doc.title }}
<span class="ml-[4px] text-[12px] text-[#999]">
{{ doc.segments.length }}
</span>
</div>
</div>
</div>
</div>
<Tooltip placement="topLeft" trigger="click">
<div ref="documentRef"></div>
<template #title>
<div class="mb-[12px] text-[16px] font-bold">{{ document?.title }}</div>
<div class="max-h-[60vh] overflow-y-auto">
<div
v-for="(segment, index) in document?.segments"
:key="index"
class="border-b-solid border-b-[#eee] p-[12px] last:border-b-0"
>
<div
class="mb-[8px] block w-fit rounded-[4px] bg-[#f5f5f5] px-[8px] py-[2px] text-[12px] text-[#666]"
>
分段 {{ segment.id }}
</div>
<div class="mt-[10px] text-[14px] leading-[1.6] text-[#333]">
{{ segment.content }}
</div>
</div>
</div>
</template>
</Tooltip>
</template>

View File

@ -0,0 +1,220 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiChatMessageApi } from '#/api/ai/chat/message';
import { computed, nextTick, onMounted, ref, toRefs } from 'vue';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { formatDate } from '@vben/utils';
import { useClipboard } from '@vueuse/core';
import { Avatar, Button, message } from 'ant-design-vue';
import { deleteChatMessage } from '#/api/ai/chat/message';
import MarkdownView from '#/components/MarkdownView/index.vue';
import MessageKnowledge from './MessageKnowledge.vue';
// props
const props = defineProps({
conversation: {
type: Object as PropType<AiChatConversationApi.ChatConversationVO>,
required: true,
},
list: {
type: Array as PropType<AiChatMessageApi.ChatMessageVO[]>,
required: true,
},
});
//
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']);
const { copy } = useClipboard(); // copy
const userStore = useUserStore();
// ()
const messageContainer: any = ref(null);
const isScrolling = ref(false); //
const userAvatar = computed(
() => userStore.userInfo?.avatar || preferences.app.defaultAvatar,
);
const roleAvatar = computed(
() => props.conversation.roleAvatar ?? '/static/gpt.svg',
);
const { list } = toRefs(props); // emits
// ============ ==============
/** 滚动到底部 */
const scrollToBottom = async (isIgnore?: boolean) => {
// 使 nextTick dom
await nextTick();
if (isIgnore || !isScrolling.value) {
messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight;
}
};
function handleScroll() {
const scrollContainer = messageContainer.value;
const scrollTop = scrollContainer.scrollTop;
const scrollHeight = scrollContainer.scrollHeight;
const offsetHeight = scrollContainer.offsetHeight;
isScrolling.value = scrollTop + offsetHeight < scrollHeight - 100;
}
/** 回到底部 */
const handleGoBottom = async () => {
const scrollContainer = messageContainer.value;
scrollContainer.scrollTop = scrollContainer.scrollHeight;
};
/** 回到顶部 */
const handlerGoTop = async () => {
const scrollContainer = messageContainer.value;
scrollContainer.scrollTop = 0;
};
defineExpose({ scrollToBottom, handlerGoTop }); // parent
// ============ ==============
/** 复制 */
const copyContent = async (content: string) => {
await copy(content);
message.success('复制成功!');
};
/** 删除 */
const onDelete = async (id: number) => {
// message
await deleteChatMessage(id);
message.success('删除成功!');
//
emits('onDeleteSuccess');
};
/** 刷新 */
const onRefresh = async (message: AiChatMessageApi.ChatMessageVO) => {
emits('onRefresh', message);
};
/** 编辑 */
const onEdit = async (message: AiChatMessageApi.ChatMessageVO) => {
emits('onEdit', message);
};
/** 初始化 */
onMounted(async () => {
messageContainer.value.addEventListener('scroll', handleScroll);
});
</script>
<template>
<div ref="messageContainer" class="relative h-full overflow-y-auto">
<div
v-for="(item, index) in list"
:key="index"
class="mt-[50px] flex flex-col overflow-y-hidden px-[20px]"
>
<!-- 左侧消息systemassistant -->
<div v-if="item.type !== 'user'" class="flex flex-row">
<div class="avatar">
<Avatar :src="roleAvatar" />
</div>
<div class="mx-[15px] flex flex-col text-left">
<div class="text-left leading-[30px]">
{{ formatDate(item.createTime) }}
</div>
<div
class="relative flex flex-col break-words rounded-[10px] bg-[#e4e4e4cc] p-[10px] pb-[5px] pt-[10px] shadow-[0_0_0_1px_rgba(228,228,228,0.8)]"
>
<MarkdownView
class="text-[0.95rem] text-[#393939]"
:content="item.content"
/>
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
</div>
<div class="mt-[8px] flex flex-row">
<Button
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="copyContent(item.content)"
>
<img class="h-[20px]" src="/static/copy.svg" />
</Button>
<Button
v-if="item.id > 0"
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="onDelete(item.id)"
>
<img class="h-[17px]" src="/static/delete.svg" />
</Button>
</div>
</div>
</div>
<!-- 右侧消息user -->
<div v-else class="flex flex-row-reverse justify-start">
<div class="avatar">
<Avatar :src="userAvatar" />
</div>
<div class="mx-[15px] flex flex-col text-left">
<div class="text-left leading-[30px]">
{{ formatDate(item.createTime) }}
</div>
<div class="flex flex-row-reverse">
<div
class="inline w-auto whitespace-pre-wrap break-words rounded-[10px] bg-[#267fff] p-[10px] text-[0.95rem] text-white shadow-[0_0_0_1px_#267fff]"
>
{{ item.content }}
</div>
</div>
<div class="mt-[8px] flex flex-row-reverse">
<Button
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="copyContent(item.content)"
>
<img class="h-[20px]" src="/static/copy.svg" />
</Button>
<Button
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="onDelete(item.id)"
>
<img class="h-[17px]" src="/static/delete.svg" />
</Button>
<Button
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="onRefresh(item)"
>
<span class="icon-[ant-design--redo-outlined]"></span>
</Button>
<Button
class="flex items-center bg-transparent px-[5px] hover:bg-[#f6f6f6]"
type="text"
@click="onEdit(item)"
>
<span class="icon-[ant-design--form-outlined]"></span>
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 回到底部按钮 -->
<div
v-if="isScrolling"
class="absolute bottom-0 right-1/2 z-[1000]"
@click="handleGoBottom"
>
<Button shape="circle">
<span class="icon-[ant-design--down-outlined]"></span>
</Button>
</div>
</template>

View File

@ -0,0 +1,40 @@
<!-- 消息列表为空时展示 prompt 列表 -->
<script setup lang="ts">
// prompt
const emits = defineEmits(['onPrompt']);
const promptList = [
{
prompt: '今天气怎么样?',
},
{
prompt: '写一首好听的诗歌?',
},
]; /** 选中 prompt 点击 */
const handlerPromptClick = async (prompt: any) => {
emits('onPrompt', prompt.prompt);
};
</script>
<template>
<div class="relative flex h-full w-full flex-row justify-center">
<!-- center-container -->
<div class="flex flex-col justify-center">
<!-- title -->
<div class="text-center text-[28px] font-bold">芋道 AI</div>
<!-- role-list -->
<div
class="mt-[20px] flex w-[460px] flex-wrap items-center justify-center"
>
<div
v-for="prompt in promptList"
:key="prompt.prompt"
@click="handlerPromptClick(prompt)"
class="m-[10px] flex w-[180px] cursor-pointer justify-center rounded-[10px] border border-[#e4e4e4] leading-[50px] hover:bg-[rgba(243,243,243,0.73)]"
>
{{ prompt.prompt }}
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import { Skeleton } from 'ant-design-vue';
</script>
<template>
<div class="p-[30px]">
<Skeleton active />
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { Button } from 'ant-design-vue';
const emits = defineEmits(['onNewConversation']);
/** 新建 conversation 聊天对话 */
const handlerNewChat = () => {
emits('onNewConversation');
};
</script>
<template>
<div class="flex h-full w-full flex-row justify-center">
<div class="flex flex-col justify-center">
<div class="text-center text-[14px] text-[#858585]">
点击下方按钮开始你的对话吧
</div>
<div class="mt-[20px] flex flex-row justify-center">
<Button type="primary" round @click="handlerNewChat"></Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import { Button } from 'ant-design-vue';
//
defineProps({
categoryList: {
type: Array as PropType<string[]>,
required: true,
},
active: {
type: String,
required: false,
default: '全部',
},
});
//
const emits = defineEmits(['onCategoryClick']);
/** 处理分类点击事件 */
const handleCategoryClick = async (category: string) => {
emits('onCategoryClick', category);
};
</script>
<template>
<div class="flex flex-wrap items-center">
<div
class="mr-[10px] flex flex-row"
v-for="category in categoryList"
:key="category"
>
<Button
size="small"
shape="round"
:type="category === active ? 'primary' : 'default'"
@click="handleCategoryClick(category)"
>
{{ category }}
</Button>
</div>
</div>
</template>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Card, Dropdown, Menu } from 'ant-design-vue';
// tabs ref
//
const props = defineProps({
loading: {
type: Boolean,
required: true,
},
roleList: {
type: Array as PropType<AiModelChatRoleApi.ChatRoleVO[]>,
required: true,
},
showMore: {
type: Boolean,
required: false,
default: false,
},
});
//
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage']);
const tabsRef = ref<any>();
/** 操作:编辑、删除 */
const handleMoreClick = async (data: any) => {
const type = data[0];
const role = data[1];
if (type === 'delete') {
emits('onDelete', role);
} else {
emits('onEdit', role);
}
};
/** 选中 */
const handleUseClick = (role: any) => {
emits('onUse', role);
};
/** 滚动 */
const handleTabsScroll = async () => {
if (tabsRef.value) {
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
await emits('onPage');
}
}
};
</script>
<template>
<div
class="relative flex h-full flex-wrap content-start items-start overflow-auto px-[25px] pb-[140px]"
ref="tabsRef"
@scroll="handleTabsScroll"
>
<div
class="mb-[20px] mr-[20px] inline-block"
v-for="role in roleList"
:key="role.id"
>
<Card
class="relative rounded-[10px]"
:body-style="{
position: 'relative',
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
width: '240px',
maxWidth: '240px',
padding: '15px 15px 10px',
}"
>
<!-- 更多操作 -->
<div v-if="showMore" class="absolute right-[12px] top-0">
<Dropdown>
<Button type="text">
<span class="icon-[ant-design--more-outlined] text-2xl"></span>
</Button>
<template #overlay>
<Menu>
<Menu.Item @click="handleMoreClick(['edit', role])">
<div class="flex items-center">
<IconifyIcon icon="ep:edit" color="#787878" />
<span>编辑</span>
</div>
</Menu.Item>
<Menu.Item @click="handleMoreClick(['delete', role])">
<div class="flex items-center">
<IconifyIcon icon="ep:delete" color="red" />
<span class="text-red-500">编辑</span>
</div>
</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
<!-- 角色信息 -->
<div>
<img
:src="role.avatar"
class="h-[40px] w-[40px] overflow-hidden rounded-[10px]"
/>
</div>
<div class="ml-[10px] w-full">
<div class="h-[85px]">
<div class="max-w-[140px] text-[18px] font-bold text-[#3e3e3e]">
{{ role.name }}
</div>
<div class="mt-[10px] text-[14px] text-[#6a6a6a]">
{{ role.description }}
</div>
</div>
<div class="mt-[2px] flex flex-row-reverse">
<Button type="primary" size="small" @click="handleUseClick(role)">
使用
</Button>
</div>
</div>
</Card>
</div>
</div>
</template>

View File

@ -0,0 +1,248 @@
<script setup lang="ts">
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, Input, Layout, TabPane, Tabs } from 'ant-design-vue';
import { createChatConversationMy } from '#/api/ai/chat/conversation';
import { deleteMy, getCategoryList, getMyPage } from '#/api/ai/model/chatRole';
import Form from '../../../../model/chatRole/modules/form.vue';
import RoleCategoryList from './RoleCategoryList.vue';
import RoleList from './RoleList.vue';
const router = useRouter(); //
const [Drawer] = useVbenDrawer({
title: '角色管理',
footer: false,
class: 'w-[754px]',
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
//
const loading = ref<boolean>(false); //
const activeTab = ref<string>('my-role'); // Tab
const search = ref<string>(''); //
const myRoleParams = reactive({
pageNo: 1,
pageSize: 50,
});
const myRoleList = ref<AiModelChatRoleApi.ChatRoleVO[]>([]); // my
const publicRoleParams = reactive({
pageNo: 1,
pageSize: 50,
});
const publicRoleList = ref<AiModelChatRoleApi.ChatRoleVO[]>([]); // public
const activeCategory = ref<string>('全部'); //
const categoryList = ref<string[]>([]); //
/** tabs 点击 */
const handleTabsClick = async (tab: any) => {
//
activeTab.value = tab;
//
await getActiveTabsRole();
};
/** 获取 my role 我的角色 */
const getMyRole = async (append?: boolean) => {
const params: AiModelChatRoleApi.ChatRolePageReqVO = {
...myRoleParams,
name: search.value,
publicStatus: false,
};
const { list } = await getMyPage(params);
if (append) {
myRoleList.value.push(...list);
} else {
myRoleList.value = list;
}
};
/** 获取 public role 公共角色 */
const getPublicRole = async (append?: boolean) => {
const params: AiModelChatRoleApi.ChatRolePageReqVO = {
...publicRoleParams,
category: activeCategory.value === '全部' ? '' : activeCategory.value,
name: search.value,
publicStatus: true,
};
const { list } = await getMyPage(params);
if (append) {
publicRoleList.value.push(...list);
} else {
publicRoleList.value = list;
}
};
/** 获取选中的 tabs 角色 */
const getActiveTabsRole = async () => {
if (activeTab.value === 'my-role') {
myRoleParams.pageNo = 1;
await getMyRole();
} else {
publicRoleParams.pageNo = 1;
await getPublicRole();
}
};
/** 获取角色分类列表 */
const getRoleCategoryList = async () => {
categoryList.value = ['全部', ...(await getCategoryList())];
};
/** 处理分类点击 */
const handlerCategoryClick = async (category: string) => {
//
activeCategory.value = category;
//
await getActiveTabsRole();
};
const handlerAddRole = async () => {
formModalApi.setData({ formType: 'my-create' }).open();
};
/** 编辑角色 */
const handlerCardEdit = async (role: any) => {
formModalApi.setData({ formType: 'my-update', id: role.id }).open();
};
/** 添加角色成功 */
const handlerAddRoleSuccess = async () => {
//
await getActiveTabsRole();
};
/** 删除角色 */
const handlerCardDelete = async (role: any) => {
await deleteMy(role.id);
//
await getActiveTabsRole();
};
/** 角色分页:获取下一页 */
const handlerCardPage = async (type: string) => {
try {
loading.value = true;
if (type === 'public') {
publicRoleParams.pageNo++;
await getPublicRole(true);
} else {
myRoleParams.pageNo++;
await getMyRole(true);
}
} finally {
loading.value = false;
}
};
/** 选择 card 角色:新建聊天对话 */
const handlerCardUse = async (role: any) => {
// 1.
const data: AiChatConversationApi.ChatConversationVO = {
roleId: role.id,
} as unknown as AiChatConversationApi.ChatConversationVO;
const conversationId = await createChatConversationMy(data);
// 2.
await router.push({
path: '/ai/chat',
query: {
conversationId,
},
});
};
/** 初始化 */
onMounted(async () => {
//
await getRoleCategoryList();
// role
await getActiveTabsRole();
});
</script>
<template>
<Drawer>
<Layout
class="absolute inset-0 flex h-full w-full flex-col overflow-hidden bg-white"
>
<FormModal @success="handlerAddRoleSuccess" />
<Layout.Content class="relative m-0 flex-1 overflow-hidden p-0">
<div class="absolute right-0 top-[-5px] z-[100] mr-[20px] mt-[20px]">
<!-- 搜索输入框 -->
<Input.Search
:loading="loading"
v-model:value="search"
class="w-[240px]"
placeholder="请输入搜索的内容"
@search="getActiveTabsRole"
/>
<Button
v-if="activeTab === 'my-role'"
type="primary"
@click="handlerAddRole"
class="ml-[20px]"
>
<IconifyIcon icon="ep:user" style="margin-right: 5px" />
添加角色
</Button>
</div>
<!-- 标签页内容 -->
<Tabs
v-model:value="activeTab"
class="relative h-full p-4"
@tab-click="handleTabsClick"
>
<TabPane
key="my-role"
class="flex h-full flex-col overflow-y-auto"
tab="我的角色"
>
<RoleList
:loading="loading"
:role-list="myRoleList"
:show-more="true"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('my')"
class="mt-[20px]"
/>
</TabPane>
<TabPane
key="public-role"
class="flex h-full flex-col overflow-y-auto"
tab="公共角色"
>
<RoleCategoryList
:category-list="categoryList"
:active="activeCategory"
@on-category-click="handlerCategoryClick"
class="mx-[27px]"
/>
<RoleList
:role-list="publicRoleList"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('public')"
class="mt-[20px]"
loading
/>
</TabPane>
</Tabs>
</Layout.Content>
</Layout>
</Drawer>
</template>

View File

@ -0,0 +1,79 @@
import type { VbenFormSchema } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import { AiModelTypeEnum } from '#/utils';
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'systemMessage',
label: '角色设定',
component: 'Textarea',
componentProps: {
rows: 4,
placeholder: '请输入角色设定',
},
},
{
component: 'ApiSelect',
fieldName: 'modelId',
label: '模型',
componentProps: {
api: () => getModelSimpleList(AiModelTypeEnum.CHAT),
labelField: 'name',
valueField: 'id',
allowClear: true,
placeholder: '请选择模型',
},
rules: 'required',
},
{
fieldName: 'temperature',
label: '温度参数',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入温度参数',
class: 'w-full',
precision: 2,
min: 0,
max: 2,
},
rules: 'required',
},
{
fieldName: 'maxTokens',
label: '回复数 Token 数',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入回复数 Token 数',
class: 'w-full',
min: 0,
max: 8192,
},
rules: 'required',
},
{
fieldName: 'maxContexts',
label: '上下文数量',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入上下文数量',
class: 'w-full',
min: 0,
max: 20,
},
rules: 'required',
},
];
}

View File

@ -1,28 +1,621 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiChatMessageApi } from '#/api/ai/chat/message';
import { Button } from 'ant-design-vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { alert, confirm, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, Layout, message, Switch } from 'ant-design-vue';
import { getChatConversationMy } from '#/api/ai/chat/conversation';
import {
deleteByConversationId,
getChatMessageListByConversationId,
sendChatMessageStream,
} from '#/api/ai/chat/message';
import ConversationList from './components/conversation/ConversationList.vue';
import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue';
import MessageList from './components/message/MessageList.vue';
import MessageListEmpty from './components/message/MessageListEmpty.vue';
import MessageLoading from './components/message/MessageLoading.vue';
import MessageNewConversation from './components/message/MessageNewConversation.vue';
/** AI 聊天对话 列表 */
defineOptions({ name: 'AiChat' });
const route = useRoute(); //
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: ConversationUpdateForm,
destroyOnClose: true,
});
//
const conversationListRef = ref();
const activeConversationId = ref<null | number>(null); //
const activeConversation = ref<AiChatConversationApi.ChatConversationVO | null>(
null,
); // Conversation
const conversationInProgress = ref(false); // true
//
const messageRef = ref();
const activeMessageList = ref<AiChatMessageApi.ChatMessageVO[]>([]); //
const activeMessageListLoading = ref<boolean>(false); // activeMessageList
const activeMessageListLoadingTimer = ref<any>(); // activeMessageListLoading Timer
//
const textSpeed = ref<number>(50); // Typing speed in milliseconds
const textRoleRunning = ref<boolean>(false); // Typing speed in milliseconds
//
const isComposing = ref(false); //
const conversationInAbortController = ref<any>(); // abort ( stream )
const inputTimeout = ref<any>(); //
const prompt = ref<string>(); // prompt
const enableContext = ref<boolean>(true); //
// Stream
const receiveMessageFullText = ref('');
const receiveMessageDisplayedText = ref('');
// =========== ===========
/** 获取对话信息 */
const getConversation = async (id: null | number) => {
if (!id) {
return;
}
const conversation: AiChatConversationApi.ChatConversationVO =
await getChatConversationMy(id);
if (!conversation) {
return;
}
activeConversation.value = conversation;
activeConversationId.value = conversation.id;
};
/**
* 点击某个对话
*
* @param conversation 选中的对话
* @return 是否切换成功
*/
const handleConversationClick = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
//
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
// id
activeConversationId.value = conversation.id;
activeConversation.value = conversation;
// message
await getMessageList();
//
scrollToBottom(true);
//
prompt.value = '';
return true;
};
/** 删除某个对话*/
const handlerConversationDelete = async (
delConversation: AiChatConversationApi.ChatConversationVO,
) => {
//
if (activeConversationId.value === delConversation.id) {
await handleConversationClear();
}
};
/** 清空选中的对话 */
const handleConversationClear = async () => {
//
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
activeConversationId.value = null;
activeConversation.value = null;
activeMessageList.value = [];
};
const openChatConversationUpdateForm = async () => {
formModalApi.setData({ id: activeConversationId.value }).open();
};
const handleConversationUpdateSuccess = async () => {
//
await getConversation(activeConversationId.value);
};
/** 处理聊天对话的创建成功 */
const handleConversationCreate = async () => {
//
await conversationListRef.value.createConversation();
};
/** 处理聊天对话的创建成功 */
const handleConversationCreateSuccess = async () => {
//
prompt.value = '';
};
// =========== ===========
/** 获取消息 message 列表 */
const getMessageList = async () => {
try {
if (activeConversationId.value === null) {
return;
}
// Timer
activeMessageListLoadingTimer.value = setTimeout(() => {
activeMessageListLoading.value = true;
}, 60);
//
activeMessageList.value = await getChatMessageListByConversationId(
activeConversationId.value,
);
//
await nextTick();
await scrollToBottom();
} finally {
// time
if (activeMessageListLoadingTimer.value) {
clearTimeout(activeMessageListLoadingTimer.value);
}
//
activeMessageListLoading.value = false;
}
};
/**
* 消息列表
*
* {@link #getMessageList()} 的差异是 systemMessage 考虑进去
*/
const messageList = computed(() => {
if (activeMessageList.value.length > 0) {
return activeMessageList.value;
}
// systemMessage
if (activeConversation.value?.systemMessage) {
return [
{
id: 0,
type: 'system',
content: activeConversation.value.systemMessage,
},
];
}
return [];
});
/** 处理删除 message 消息 */
const handleMessageDelete = () => {
if (conversationInProgress.value) {
alert('回答中,不能删除!');
return;
}
// message
getMessageList();
};
/** 处理 message 清空 */
const handlerMessageClear = async () => {
if (!activeConversationId.value) {
return;
}
try {
//
await confirm('确认清空对话消息?');
//
await deleteByConversationId(activeConversationId.value);
// message
activeMessageList.value = [];
} catch {}
};
/** 回到 message 列表的顶部 */
const handleGoTopMessage = () => {
messageRef.value.handlerGoTop();
};
// =========== ===========
/** 处理来自 keydown 的发送消息 */
const handleSendByKeydown = async (event: any) => {
//
if (isComposing.value) {
return;
}
//
if (conversationInProgress.value) {
return;
}
const content = prompt.value?.trim() as string;
if (event.key === 'Enter') {
if (event.shiftKey) {
//
prompt.value += '\r\n';
event.preventDefault(); //
} else {
//
await doSendMessage(content);
event.preventDefault(); //
}
}
};
/** 处理来自【发送】按钮的发送消息 */
const handleSendByButton = () => {
doSendMessage(prompt.value?.trim() as string);
};
/** 处理 prompt 输入变化 */
const handlePromptInput = (event) => {
// true
if (!isComposing.value) {
// event data null
if (event.data === null || event.data === 'null') {
return;
}
isComposing.value = true;
}
//
if (inputTimeout.value) {
clearTimeout(inputTimeout.value);
}
//
inputTimeout.value = setTimeout(() => {
isComposing.value = false;
}, 400);
};
const onCompositionstart = () => {
isComposing.value = true;
};
const onCompositionend = () => {
// console.log('...')
setTimeout(() => {
isComposing.value = false;
}, 200);
};
/** 真正执行【发送】消息操作 */
const doSendMessage = async (content: string) => {
//
if (content.length === 0) {
message.error('发送失败,原因:内容为空!');
return;
}
if (activeConversationId.value == null) {
message.error('还没创建对话,不能发送!');
return;
}
//
prompt.value = '';
//
await doSendMessageStream({
conversationId: activeConversationId.value,
content,
} as AiChatMessageApi.ChatMessageVO);
};
/** 真正执行【发送】消息操作 */
const doSendMessageStream = async (
userMessage: AiChatMessageApi.ChatMessageVO,
) => {
// AbortController 便
conversationInAbortController.value = new AbortController();
//
conversationInProgress.value = true;
//
receiveMessageFullText.value = '';
try {
// 1.1 stream
activeMessageList.value.push(
{
id: -1,
conversationId: activeConversationId.value,
type: 'user',
content: userMessage.content,
createTime: new Date(),
} as AiChatMessageApi.ChatMessageVO,
{
id: -2,
conversationId: activeConversationId.value,
type: 'assistant',
content: '思考中...',
createTime: new Date(),
} as AiChatMessageApi.ChatMessageVO,
);
// 1.2
await nextTick();
await scrollToBottom(); //
// 1.3
textRoll();
// 2. event stream
let isFirstChunk = true; // chunk
await sendChatMessageStream(
userMessage.conversationId,
userMessage.content,
conversationInAbortController.value,
enableContext.value,
async (res: any) => {
const { code, data, msg } = JSON.parse(res.data);
if (code !== 0) {
alert(`对话异常! ${msg}`);
return;
}
//
if (data.receive.content === '') {
return;
}
// message
if (isFirstChunk) {
isFirstChunk = false;
//
activeMessageList.value.pop();
activeMessageList.value.pop();
//
activeMessageList.value.push(data.send, data.receive);
}
// debugger
receiveMessageFullText.value =
receiveMessageFullText.value + data.receive.content;
//
await scrollToBottom();
},
(error: any) => {
alert(`对话异常! ${error}`);
stopStream();
//
throw error;
},
() => {
stopStream();
},
);
} catch {}
};
/** 停止 stream 流式调用 */
const stopStream = async () => {
// tip stream message controller
if (conversationInAbortController.value) {
conversationInAbortController.value.abort();
}
// false
conversationInProgress.value = false;
};
/** 编辑 message设置为 prompt可以再次编辑 */
const handleMessageEdit = (message: AiChatMessageApi.ChatMessageVO) => {
prompt.value = message.content;
};
/** 刷新 message基于指定消息再次发起对话 */
const handleMessageRefresh = (message: AiChatMessageApi.ChatMessageVO) => {
doSendMessage(message.content);
};
// ============== =============
/** 滚动到 message 底部 */
const scrollToBottom = async (isIgnore?: boolean) => {
await nextTick();
if (messageRef.value) {
messageRef.value.scrollToBottom(isIgnore);
}
};
/** 自提滚动效果 */
const textRoll = async () => {
let index = 0;
try {
//
if (textRoleRunning.value) {
return;
}
//
textRoleRunning.value = true;
receiveMessageDisplayedText.value = '';
const task = async () => {
//
const diff =
(receiveMessageFullText.value.length -
receiveMessageDisplayedText.value.length) /
10;
if (diff > 5) {
textSpeed.value = 10;
} else if (diff > 2) {
textSpeed.value = 30;
} else if (diff > 1.5) {
textSpeed.value = 50;
} else {
textSpeed.value = 100;
}
// 30
if (!conversationInProgress.value) {
textSpeed.value = 10;
}
if (index < receiveMessageFullText.value.length) {
receiveMessageDisplayedText.value +=
receiveMessageFullText.value[index];
index++;
// message
const lastMessage =
activeMessageList.value[activeMessageList.value.length - 1];
if (lastMessage)
lastMessage.content = receiveMessageDisplayedText.value;
//
await scrollToBottom();
//
timer = setTimeout(task, textSpeed.value);
} else {
//
if (conversationInProgress.value) {
//
timer = setTimeout(task, textSpeed.value);
} else {
textRoleRunning.value = false;
clearTimeout(timer);
}
}
};
let timer = setTimeout(task, textSpeed.value);
} catch {}
};
/** 初始化 */
onMounted(async () => {
// conversationId
if (route.query.conversationId) {
const id = route.query.conversationId as unknown as number;
activeConversationId.value = id;
await getConversation(id);
}
//
activeMessageListLoading.value = true;
await getMessageList();
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Layout class="absolute left-0 top-0 h-full w-full flex-1">
<!-- 左侧对话列表 -->
<ConversationList
:active-id="activeConversationId"
ref="conversationListRef"
@on-conversation-create="handleConversationCreateSuccess"
@on-conversation-click="handleConversationClick"
@on-conversation-clear="handleConversationClear"
@on-conversation-delete="handlerConversationDelete"
/>
<!-- 右侧详情部分 -->
<Layout class="bg-white">
<Layout.Header
class="flex items-center justify-between bg-[#fbfbfb!important] shadow-none"
>
<div class="text-[18px] font-bold">
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
<span v-if="activeMessageList.length > 0">
({{ activeMessageList.length }})
</span>
</div>
<div class="flex w-[300px] justify-end" v-if="activeConversation">
<Button
type="primary"
ghost
class="mr-[10px] px-[10px]"
size="small"
@click="openChatConversationUpdateForm"
>
<span v-html="activeConversation?.modelName"></span>
<IconifyIcon icon="ep:setting" class="ml-[10px]" />
</Button>
<Button
size="small"
class="mr-[10px] px-[10px]"
@click="handlerMessageClear"
>
<IconifyIcon
icon="heroicons-outline:archive-box-x-mark"
color="#787878"
/>
</Button>
<Button size="small" class="mr-[10px] px-[10px]">
<IconifyIcon icon="ep:download" color="#787878" />
</Button>
<Button
size="small"
class="mr-[10px] px-[10px]"
@click="handleGoTopMessage"
>
<IconifyIcon icon="ep:top" color="#787878" />
</Button>
</div>
</Layout.Header>
<Layout.Content class="relative m-0 h-full w-full p-0">
<div class="absolute inset-0 m-0 overflow-y-hidden p-0">
<MessageLoading v-if="activeMessageListLoading" />
<MessageNewConversation
v-if="!activeConversation"
@on-new-conversation="handleConversationCreate"
/>
<MessageListEmpty
v-if="
!activeMessageListLoading &&
messageList.length === 0 &&
activeConversation
"
@on-prompt="doSendMessage"
/>
<MessageList
v-if="!activeMessageListLoading && messageList.length > 0"
ref="messageRef"
:conversation="activeConversation"
:list="messageList"
@on-delete-success="handleMessageDelete"
@on-edit="handleMessageEdit"
@on-refresh="handleMessageRefresh"
/>
</div>
</Layout.Content>
<Layout.Footer class="m-0 flex flex-col bg-[white!important] p-0">
<form
class="m-[10px_20px_20px] flex flex-col rounded-[10px] border border-[#e3e3e3] p-[9px_10px]"
>
<textarea
class="box-border h-[80px] resize-none overflow-auto border-none p-[0_2px] focus:outline-none"
v-model="prompt"
@keydown="handleSendByKeydown"
@input="handlePromptInput"
@compositionstart="onCompositionstart"
@compositionend="onCompositionend"
placeholder="问我任何问题...Shift+Enter 换行,按下 Enter 发送)"
></textarea>
<div class="flex justify-between pb-0 pt-[5px]">
<div class="flex items-center">
<Switch v-model:checked="enableContext" />
<span class="ml-[5px] text-[14px] text-[#8f8f8f]">上下文</span>
</div>
<Button
type="primary"
@click="handleSendByButton"
:loading="conversationInProgress"
v-if="conversationInProgress === false"
>
{{ conversationInProgress ? '进行中' : '发送' }}
</Button>
<Button
danger
@click="stopStream()"
v-if="conversationInProgress === true"
>
停止
</Button>
</div>
</form>
</Layout.Footer>
</Layout>
</Layout>
<FormModal @success="handleConversationUpdateSuccess" />
</Page>
</template>

View File

@ -0,0 +1,196 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchemaConversation(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
},
{
fieldName: 'title',
label: '聊天标题',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumnsConversation(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '对话编号',
fixed: 'left',
minWidth: 180,
},
{
field: 'title',
title: '对话标题',
minWidth: 180,
fixed: 'left',
},
{
title: '用户',
width: 180,
slots: { default: 'userId' },
},
{
field: 'roleName',
title: '角色',
minWidth: 180,
},
{
field: 'model',
title: '模型标识',
minWidth: 180,
},
{
field: 'messageCount',
title: '消息数',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'temperature',
title: '温度参数',
minWidth: 80,
},
{
title: '回复数 Token 数',
field: 'maxTokens',
minWidth: 120,
},
{
title: '上下文数量',
field: 'maxContexts',
minWidth: 120,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchemaMessage(): VbenFormSchema[] {
return [
{
fieldName: 'conversationId',
label: '对话编号',
component: 'Input',
},
{
fieldName: 'userId',
label: '用户编号',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumnsMessage(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '消息编号',
fixed: 'left',
minWidth: 180,
},
{
field: 'conversationId',
title: '对话编号',
minWidth: 180,
fixed: 'left',
},
{
title: '用户',
width: 180,
slots: { default: 'userId' },
},
{
field: 'roleName',
title: '角色',
minWidth: 180,
},
{
field: 'type',
title: '消息类型',
minWidth: 100,
},
{
field: 'model',
title: '模型标识',
minWidth: 180,
},
{
field: 'content',
title: '消息内容',
minWidth: 300,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'replyId',
title: '回复消息编号',
minWidth: 180,
},
{
title: '携带上下文',
field: 'useContext',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
minWidth: 100,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,30 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Card, TabPane, Tabs } from 'ant-design-vue';
import ChatConversationList from './modules/ChatConversationList.vue';
import ChatMessageList from './modules/ChatMessageList.vue';
const activeTabName = ref('conversation');
</script>
<template>
<Page>
<Page auto-content-height>
<template #doc>
<DocAlert title="AI 对话聊天" url="https://doc.iocoder.cn/ai/chat/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/manager/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/manager/index.vue
代码pull request 贡献给我们
</Button>
<Card>
<Tabs v-model:active-key="activeTabName">
<TabPane tab="对话列表" key="conversation">
<ChatConversationList />
</TabPane>
<TabPane tab="消息列表" key="message">
<ChatMessageList />
</TabPane>
</Tabs>
</Card>
</Page>
</template>

View File

@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteChatConversationByAdmin,
getChatConversationPage,
} from '#/api/ai/chat/conversation';
import { getSimpleUserList } from '#/api/system/user';
import { $t } from '#/locales';
import {
useGridColumnsConversation,
useGridFormSchemaConversation,
} from '../data';
const userList = ref<SystemUserApi.User[]>([]); //
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 删除 */
async function handleDelete(row: AiChatConversationApi.ChatConversationVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteChatConversationByAdmin(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchemaConversation(),
},
gridOptions: {
columns: useGridColumnsConversation(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getChatConversationPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiChatConversationApi.ChatConversationVO>,
});
onMounted(async () => {
//
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="">
<template #toolbar-tools>
<TableAction :actions="[]" />
</template>
<template #userId="{ row }">
<span>{{
userList.find((item) => item.id === row.userId)?.nickname
}}</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:chat-conversation:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,110 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteChatMessageByAdmin,
getChatMessagePage,
} from '#/api/ai/chat/message';
import { getSimpleUserList } from '#/api/system/user';
import { $t } from '#/locales';
import { useGridColumnsMessage, useGridFormSchemaMessage } from '../data';
const userList = ref<SystemUserApi.User[]>([]); //
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 删除 */
async function handleDelete(row: AiChatConversationApi.ChatConversationVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteChatMessageByAdmin(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchemaMessage(),
},
gridOptions: {
columns: useGridColumnsMessage(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getChatMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiChatConversationApi.ChatConversationVO>,
});
onMounted(async () => {
//
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="">
<template #toolbar-tools>
<TableAction :actions="[]" />
</template>
<template #userId="{ row }">
<span>{{
userList.find((item) => item.id === row.userId)?.nickname
}}</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:chat-message:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,138 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, ref, toRefs, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, Card, Image, message } from 'ant-design-vue';
import { AiImageStatusEnum } from '#/utils/constants';
//
const props = defineProps({
detail: {
type: Object as PropType<AiImageApi.ImageVO>,
default: () => ({}),
},
});
const emits = defineEmits(['onBtnClick', 'onMjBtnClick']);
const cardImageRef = ref<any>(); // image ref
/** 处理点击事件 */
const handleButtonClick = async (type: string, detail: AiImageApi.ImageVO) => {
emits('onBtnClick', type, detail);
};
/** 处理 Midjourney 按钮点击事件 */
const handleMidjourneyBtnClick = async (
button: AiImageApi.ImageMidjourneyButtonsVO,
) => {
//
await confirm(`确认操作 "${button.label} ${button.emoji}" ?`);
emits('onMjBtnClick', button, props.detail);
};
// emits
/** 监听详情 */
const { detail } = toRefs(props);
watch(detail, async (newVal) => {
await handleLoading(newVal.status);
});
const loading = ref();
/** 处理加载状态 */
const handleLoading = async (status: number) => {
// loading
if (status === AiImageStatusEnum.IN_PROGRESS) {
loading.value = message.loading({
content: `生成中...`,
});
// loading
} else {
if (loading.value) setTimeout(loading.value, 100);
}
};
/** 初始化 */
onMounted(async () => {
await handleLoading(props.detail.status);
});
</script>
<template>
<Card
body-class=""
class="relative flex h-auto w-[320px] flex-col rounded-[10px]"
>
<!-- 图片操作区 -->
<div class="flex flex-row justify-between">
<div>
<Button v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
生成中
</Button>
<Button v-else-if="detail?.status === AiImageStatusEnum.SUCCESS">
已完成
</Button>
<Button danger v-else-if="detail?.status === AiImageStatusEnum.FAIL">
异常
</Button>
</div>
<div class="flex">
<Button
class="m-0 p-[10px]"
type="text"
@click="handleButtonClick('download', detail)"
>
<span class="icon-[ant-design--download-outlined]"></span>
</Button>
<Button
class="m-0 p-[10px]"
type="text"
@click="handleButtonClick('regeneration', detail)"
>
<span class="icon-[ant-design--redo-outlined]"></span>
</Button>
<Button
class="m-0 p-[10px]"
type="text"
@click="handleButtonClick('delete', detail)"
>
<span class="icon-[ant-design--delete-outlined]"></span>
</Button>
<Button
class="m-0 p-[10px]"
type="text"
@click="handleButtonClick('more', detail)"
>
<span class="icon-[ant-design--more-outlined]"></span>
</Button>
</div>
</div>
<!-- 图片展示区域 -->
<div class="mt-[20px] h-[280px] flex-1 overflow-hidden" ref="cardImageRef">
<Image class="w-full rounded-[10px]" :src="detail?.picUrl" />
<div v-if="detail?.status === AiImageStatusEnum.FAIL">
{{ detail?.errorMessage }}
</div>
</div>
<!-- Midjourney 专属操作按钮 -->
<div class="mt-[5px] flex w-full flex-wrap justify-start">
<Button
size="small"
v-for="(button, index) in detail?.buttons"
:key="index"
class="ml-0 mr-[10px] mt-[5px] min-w-[40px]"
@click="handleMidjourneyBtnClick(button)"
>
{{ button.label }}{{ button.emoji }}
</Button>
</div>
</Card>
</template>

View File

@ -0,0 +1,209 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import { ref, toRefs, watch } from 'vue';
import { Image } from 'ant-design-vue';
import { getImageMy } from '#/api/ai/image';
import {
AiPlatformEnum,
Dall3StyleList,
StableDiffusionClipGuidancePresets,
StableDiffusionSamplers,
StableDiffusionStylePresets,
} from '#/utils/constants';
import { formatTime } from '#/utils/formatTime';
//
const props = defineProps({
id: {
type: Number,
required: true,
},
});
const detail = ref<AiImageApi.ImageVO>({} as AiImageApi.ImageVO);
/** 获取图片详情 */
const getImageDetail = async (id: number) => {
detail.value = await getImageMy(id);
};
const { id } = toRefs(props);
watch(
id,
async (newVal) => {
if (newVal) {
await getImageDetail(newVal);
}
},
{ immediate: true },
);
</script>
<template>
<div class="mb-5 w-full overflow-hidden break-words">
<div class="body mt-2 text-gray-600">
<Image class="rounded-[10px]" :src="detail?.picUrl" />
</div>
</div>
<!-- 时间 -->
<div class="mb-5 w-full overflow-hidden break-words">
<div class="tip text-lg font-bold">时间</div>
<div class="body mt-2 text-gray-600">
<div>
提交时间{{ formatTime(detail.createTime, 'yyyy-MM-dd HH:mm:ss') }}
</div>
<div>
生成时间{{ formatTime(detail.finishTime, 'yyyy-MM-dd HH:mm:ss') }}
</div>
</div>
</div>
<!-- 模型 -->
<div class="mb-5 w-full overflow-hidden break-words">
<div class="tip text-lg font-bold">模型</div>
<div class="body mt-2 text-gray-600">
{{ detail.model }}({{ detail.height }}x{{ detail.width }})
</div>
</div>
<!-- 提示词 -->
<div class="mb-5 w-full overflow-hidden break-words">
<div class="tip text-lg font-bold">提示词</div>
<div class="body mt-2 text-gray-600">
{{ detail.prompt }}
</div>
</div>
<!-- 图片地址 -->
<div class="mb-5 w-full overflow-hidden break-words">
<div class="tip text-lg font-bold">图片地址</div>
<div class="body mt-2 text-gray-600">
{{ detail.picUrl }}
</div>
</div>
<!-- StableDiffusion 专属 -->
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.sampler
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">采样方法</div>
<div class="body mt-2 text-gray-600">
{{
StableDiffusionSamplers.find(
(item) => item.key === detail?.options?.sampler,
)?.name
}}
</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.clipGuidancePreset
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">CLIP</div>
<div class="body mt-2 text-gray-600">
{{
StableDiffusionClipGuidancePresets.find(
(item) => item.key === detail?.options?.clipGuidancePreset,
)?.name
}}
</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.stylePreset
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">风格</div>
<div class="body mt-2 text-gray-600">
{{
StableDiffusionStylePresets.find(
(item) => item.key === detail?.options?.stylePreset,
)?.name
}}
</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.steps
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">迭代步数</div>
<div class="body mt-2 text-gray-600">{{ detail?.options?.steps }}</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.scale
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">引导系数</div>
<div class="body mt-2 text-gray-600">{{ detail?.options?.scale }}</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.seed
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">随机因子</div>
<div class="body mt-2 text-gray-600">{{ detail?.options?.seed }}</div>
</div>
<!-- Dall3 专属 -->
<div
v-if="detail.platform === AiPlatformEnum.OPENAI && detail?.options?.style"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">风格选择</div>
<div class="body mt-2 text-gray-600">
{{
Dall3StyleList.find((item) => item.key === detail?.options?.style)?.name
}}
</div>
</div>
<!-- Midjourney 专属 -->
<div
v-if="
detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.version
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">模型版本</div>
<div class="body mt-2 text-gray-600">{{ detail?.options?.version }}</div>
</div>
<div
v-if="
detail.platform === AiPlatformEnum.MIDJOURNEY &&
detail?.options?.referImageUrl
"
class="mb-5 w-full overflow-hidden break-words"
>
<div class="tip text-lg font-bold">参考图</div>
<div class="body mt-2 text-gray-600">
<Image :src="detail.options.referImageUrl" />
</div>
</div>
</template>

View File

@ -0,0 +1,218 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, useVbenDrawer } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Button, Card, message, Pagination } from 'ant-design-vue';
import {
deleteImageMy,
getImageListMyByIds,
getImagePageMy,
midjourneyAction,
} from '#/api/ai/image';
import { AiImageStatusEnum } from '#/utils/constants';
import { download } from '#/utils/download';
import ImageCard from './ImageCard.vue';
import ImageDetail from './ImageDetail.vue';
//
const emits = defineEmits(['onRegeneration']);
const router = useRouter(); //
const [Drawer, drawerApi] = useVbenDrawer({
title: '图片详情',
footer: false,
});
//
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
});
const pageTotal = ref<number>(0); // page size
const imageList = ref<AiImageApi.ImageVO[]>([]); // image
const imageListRef = ref<any>(); // ref
//
const inProgressImageMap = ref<{}>({}); // image key image value image
const inProgressTimer = ref<any>(); // image
const showImageDetailId = ref<number>(0); //
/** 处理查看绘图作品 */
const handleViewPublic = () => {
router.push({
name: 'AiImageSquare',
});
};
/** 查看图片的详情 */
const handleDetailOpen = async () => {
drawerApi.open();
};
/** 获得 image 图片列表 */
const getImageList = async () => {
const loading = message.loading({
content: `加载中...`,
});
try {
// 1.
const { list, total } = await getImagePageMy(queryParams);
imageList.value = list;
pageTotal.value = total;
// 2.
const newWatImages: any = {};
imageList.value.forEach((item: any) => {
if (item.status === AiImageStatusEnum.IN_PROGRESS) {
newWatImages[item.id] = item;
}
});
inProgressImageMap.value = newWatImages;
} finally {
// Loading
loading();
}
};
const debounceGetImageList = useDebounceFn(getImageList, 80);
/** 轮询生成中的 image 列表 */
const refreshWatchImages = async () => {
const imageIds = Object.keys(inProgressImageMap.value).map(Number);
if (imageIds.length === 0) {
return;
}
const list = (await getImageListMyByIds(imageIds)) as AiImageApi.ImageVO[];
const newWatchImages: any = {};
list.forEach((image) => {
if (image.status === AiImageStatusEnum.IN_PROGRESS) {
newWatchImages[image.id] = image;
} else {
const index = imageList.value.findIndex(
(oldImage) => image.id === oldImage.id,
);
if (index !== -1) {
// imageList
imageList.value[index] = image;
}
}
});
inProgressImageMap.value = newWatchImages;
};
/** 图片的点击事件 */
const handleImageButtonClick = async (
type: string,
imageDetail: AiImageApi.ImageVO,
) => {
//
if (type === 'more') {
showImageDetailId.value = imageDetail.id;
await handleDetailOpen();
return;
}
//
if (type === 'delete') {
await confirm(`是否删除照片?`);
await deleteImageMy(imageDetail.id);
await getImageList();
message.success('删除成功!');
return;
}
//
if (type === 'download') {
await download.image({ url: imageDetail.picUrl });
return;
}
//
if (type === 'regeneration') {
await emits('onRegeneration', imageDetail);
}
};
/** 处理 Midjourney 按钮点击事件 */
const handleImageMidjourneyButtonClick = async (
button: AiImageApi.ImageMidjourneyButtonsVO,
imageDetail: AiImageApi.ImageVO,
) => {
// 1. params
const data = {
id: imageDetail.id,
customId: button.customId,
} as AiImageApi.ImageMidjourneyActionVO;
// 2. action
await midjourneyAction(data);
// 3.
await getImageList();
};
defineExpose({ getImageList }); /** 组件挂在的时候 */
onMounted(async () => {
// image
await getImageList();
// image
inProgressTimer.value = setInterval(async () => {
await refreshWatchImages();
}, 1000 * 3);
});
/** 组件取消挂在的时候 */
onUnmounted(async () => {
if (inProgressTimer.value) {
clearInterval(inProgressTimer.value);
}
});
</script>
<template>
<Drawer class="w-[600px]">
<ImageDetail :id="showImageDetailId" />
</Drawer>
<Card
class="dr-task flex h-full w-full flex-col"
:body-style="{
margin: 0,
padding: 0,
height: '100%',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}"
>
<template #title>
绘画任务
<Button @click="handleViewPublic"></Button>
</template>
<div
class="task-image-list flex flex-1 flex-wrap content-start overflow-y-auto p-5 pb-[140px] pt-5"
ref="imageListRef"
>
<ImageCard
v-for="image in imageList"
:key="image.id"
:detail="image"
@on-btn-click="handleImageButtonClick"
@on-mj-btn-click="handleImageMidjourneyButtonClick"
class="mb-5 mr-5"
/>
</div>
<div
class="task-image-pagination sticky bottom-0 z-50 flex h-[60px] items-center justify-center bg-white shadow-[0_-2px_8px_rgba(0,0,0,0.1)]"
>
<Pagination
:total="pageTotal"
:show-total="(total) => `共 ${total} 条`"
show-quick-jumper
show-size-changer
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="debounceGetImageList"
@show-size-change="debounceGetImageList"
/>
</div>
</Card>
</template>

View File

@ -0,0 +1,219 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { ref, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, InputNumber, Select, Space, Textarea } from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
ImageHotWords,
OtherPlatformEnum,
} from '#/utils/constants';
//
//
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
//
const drawIn = ref<boolean>(false); //
const selectHotWord = ref<string>(''); //
//
const prompt = ref<string>(''); //
const width = ref<number>(512); //
const height = ref<number>(512); //
const otherPlatform = ref<string>(AiPlatformEnum.TONG_YI); //
const platformModels = ref<AiModelModelApi.ModelVO[]>([]); //
const modelId = ref<number>(); //
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
//
selectHotWord.value = hotWord; //
prompt.value = hotWord; //
};
/** 图片生成 */
const handleGenerateImage = async () => {
//
await confirm(`确认生成内容?`);
try {
//
drawIn.value = true;
//
emits('onDrawStart', otherPlatform.value);
//
const form = {
platform: otherPlatform.value,
modelId: modelId.value, //
prompt: prompt.value, //
width: width.value, //
height: height.value, //
options: {},
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
//
emits('onDrawComplete', otherPlatform.value);
//
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
width.value = detail.width;
height.value = detail.height;
};
/** 平台切换 */
const handlerPlatformChange = async (platform: any) => {
//
platformModels.value = props.models.filter(
(item: AiModelModelApi.ModelVO) => item.platform === platform,
);
modelId.value =
platformModels.value.length > 0 && platformModels.value[0]
? platformModels.value[0].id
: undefined;
//
};
/** 监听 models 变化 */
watch(
() => props.models,
() => {
handlerPlatformChange(otherPlatform.value);
},
{ immediate: true, deep: true },
);
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词 + 动词 + 风格的格式使用隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="mt-[15px] w-full"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words mt-[30px] flex flex-col">
<div>
<b>随机热词</b>
</div>
<Space wrap class="word-list mt-[15px] flex flex-wrap justify-start">
<Button
shape="round"
class="btn m-0"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="group-item mt-[30px]">
<div>
<b>平台</b>
</div>
<Space wrap class="group-item-body mt-[15px] w-full">
<Select
v-model:value="otherPlatform"
placeholder="Select"
size="large"
class="!important w-[330px]"
@change="handlerPlatformChange"
>
<Select.Option
v-for="item in OtherPlatformEnum"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item mt-[30px]">
<div>
<b>模型</b>
</div>
<Space wrap class="group-item-body mt-[15px] w-full">
<Select
v-model:value="modelId"
placeholder="Select"
size="large"
class="!important w-[330px]"
>
<Select.Option
v-for="item in platformModels"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item mt-[30px]">
<div>
<b>图片尺寸</b>
</div>
<Space wrap class="group-item-body mt-[15px] flex flex-wrap gap-x-[20px]">
<InputNumber
v-model:value="width"
class="mt-[10px] w-[170px]"
placeholder="图片宽度"
/>
<InputNumber
v-model:value="height"
class="w-[170px]"
placeholder="图片高度"
/>
</Space>
</div>
<div class="btns mt-[50px] flex justify-center">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>

View File

@ -0,0 +1,265 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import type { ImageModelVO, ImageSizeVO } from '#/utils/constants';
import { ref } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, Image, message, Space, Textarea } from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
Dall3Models,
Dall3SizeList,
Dall3StyleList,
ImageHotWords,
} from '#/utils/constants';
//
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
//
const prompt = ref<string>(''); //
const drawIn = ref<boolean>(false); //
const selectHotWord = ref<string>(''); //
const selectModel = ref<string>('dall-e-3'); //
const selectSize = ref<string>('1024x1024'); // size
const style = ref<string>('vivid'); // style
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
//
selectHotWord.value = hotWord;
prompt.value = hotWord;
};
/** 选择 model 模型 */
const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key;
//
//
if (model.key === 'dall-e-3') {
// DALL-E-3
style.value = 'vivid'; // vivid
} else if (model.key === 'dall-e-2') {
// DALL-E-2
style.value = 'natural'; // DALL-E-2
}
//
//
const recommendedSize = Dall3SizeList.find(
(size) =>
(model.key === 'dall-e-3' && size.key === '1024x1024') ||
(model.key === 'dall-e-2' && size.key === '512x512'),
);
if (recommendedSize) {
selectSize.value = recommendedSize.key;
}
};
/** 选择 style 样式 */
const handleStyleClick = async (imageStyle: ImageModelVO) => {
style.value = imageStyle.key;
};
/** 选择 size 大小 */
const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectSize.value = imageSize.key;
};
/** 图片生产 */
const handleGenerateImage = async () => {
// models
const matchedModel = props.models.find(
(item) =>
item.model === selectModel.value &&
item.platform === AiPlatformEnum.OPENAI,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
//
await confirm(`确认生成内容?`);
try {
//
drawIn.value = true;
//
emits('onDrawStart', AiPlatformEnum.OPENAI);
const imageSize = Dall3SizeList.find(
(item) => item.key === selectSize.value,
) as ImageSizeVO;
const form = {
platform: AiPlatformEnum.OPENAI,
prompt: prompt.value, //
modelId: matchedModel.id, // 使
style: style.value, //
width: imageSize.width, // size
height: imageSize.height, // size
options: {
style: style.value, //
},
} as AiImageApi.ImageDrawReqVO;
//
await drawImage(form);
} finally {
//
emits('onDrawComplete', AiPlatformEnum.OPENAI);
//
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
selectModel.value = detail.model;
style.value = detail.options?.style;
const imageSize = Dall3SizeList.find(
(item) => item.key === `${detail.width}x${detail.height}`,
) as ImageSizeVO;
await handleSizeClick(imageSize);
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用"形容词 + 动词 + 风格"的格式使用""隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="mt-[15px] w-full"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words mt-[30px] flex flex-col">
<div><b>随机热词</b></div>
<Space wrap class="word-list mt-[15px] flex flex-wrap justify-start">
<Button
shape="round"
class="btn m-0"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="model mt-[30px]">
<div><b>模型选择</b></div>
<Space wrap class="model-list mt-[15px] flex flex-wrap gap-[10px]">
<div
class="modal-item flex w-[110px] cursor-pointer flex-col items-center overflow-hidden rounded-[5px] border-[3px]"
:class="[
selectModel === model.key
? 'border-[#1293ff!important]'
: 'border-transparent',
]"
v-for="model in Dall3Models"
:key="model.key"
>
<Image
:preview="false"
:src="model.image"
fit="contain"
@click="handleModelClick(model)"
/>
<div class="model-font text-[14px] font-bold text-[#3e3e3e]">
{{ model.name }}
</div>
</div>
</Space>
</div>
<div class="image-style mt-[30px]">
<div><b>风格选择</b></div>
<Space wrap class="image-style-list mt-[15px] flex flex-wrap gap-[10px]">
<div
class="image-style-item flex w-[110px] cursor-pointer flex-col items-center overflow-hidden rounded-[5px] border-[3px]"
:class="[
style === imageStyle.key ? 'border-[#1293ff]' : 'border-transparent',
]"
v-for="imageStyle in Dall3StyleList"
:key="imageStyle.key"
>
<Image
:preview="false"
:src="imageStyle.image"
fit="contain"
@click="handleStyleClick(imageStyle)"
/>
<div class="style-font text-[14px] font-bold text-[#3e3e3e]">
{{ imageStyle.name }}
</div>
</div>
</Space>
</div>
<div class="image-size mt-[30px] w-full">
<div><b>画面比例</b></div>
<Space
wrap
class="size-list mt-[20px] flex w-full flex-row justify-between"
>
<div
class="size-item flex cursor-pointer flex-col items-center"
v-for="imageSize in Dall3SizeList"
:key="imageSize.key"
@click="handleSizeClick(imageSize)"
>
<div
class="size-wrapper flex h-[50px] w-[50px] flex-col items-center justify-center rounded-[7px] border bg-white p-[4px]"
:class="[
selectSize === imageSize.key ? 'border-[#1293ff]' : 'border-white',
]"
>
<div :style="imageSize.style"></div>
</div>
<div class="size-font text-[14px] font-bold text-[#3e3e3e]">
{{ imageSize.name }}
</div>
</div>
</Space>
</div>
<div class="btns mt-[50px] flex justify-center">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>

View File

@ -0,0 +1,259 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import type { ImageModelVO, ImageSizeVO } from '#/utils/constants';
import { ref } from 'vue';
import { confirm } from '@vben/common-ui';
import {
Button,
Image,
message,
Select,
Space,
Textarea,
} from 'ant-design-vue';
import { midjourneyImagine } from '#/api/ai/image';
import { ImageUpload } from '#/components/upload';
import {
AiPlatformEnum,
ImageHotWords,
MidjourneyModels,
MidjourneySizeList,
MidjourneyVersions,
NijiVersionList,
} from '#/utils/constants';
//
//
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
//
const drawIn = ref<boolean>(false); //
const selectHotWord = ref<string>(''); //
//
const prompt = ref<string>(''); //
const referImageUrl = ref<any>(); //
const selectModel = ref<string>('midjourney'); //
const selectSize = ref<string>('1:1'); // size
const selectVersion = ref<any>('6.0'); // version
const versionList = ref<any>(MidjourneyVersions); // version
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
//
selectHotWord.value = hotWord; //
prompt.value = hotWord; //
};
/** 点击 size 尺寸 */
const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectSize.value = imageSize.key;
};
/** 点击 model 模型 */
const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key;
versionList.value =
model.key === 'niji' ? NijiVersionList : MidjourneyVersions;
selectVersion.value = versionList.value[0].value;
};
/** 图片生成 */
const handleGenerateImage = async () => {
// models
const matchedModel = props.models.find(
(item) =>
item.model === selectModel.value &&
item.platform === AiPlatformEnum.MIDJOURNEY,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
//
await confirm(`确认生成内容?`);
try {
//
drawIn.value = true;
//
emits('onDrawStart', AiPlatformEnum.MIDJOURNEY);
//
const imageSize = MidjourneySizeList.find(
(item) => selectSize.value === item.key,
) as ImageSizeVO;
const req = {
prompt: prompt.value,
modelId: matchedModel.id,
width: imageSize.width,
height: imageSize.height,
version: selectVersion.value,
referImageUrl: referImageUrl.value,
} as AiImageApi.ImageMidjourneyImagineReqVO;
await midjourneyImagine(req);
} finally {
//
emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY);
//
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
//
prompt.value = detail.prompt;
// image size
const imageSize = MidjourneySizeList.find(
(item) => item.key === `${detail.width}:${detail.height}`,
) as ImageSizeVO;
selectSize.value = imageSize.key;
//
const model = MidjourneyModels.find(
(item) => item.key === detail.options?.model,
) as ImageModelVO;
await handleModelClick(model);
//
selectVersion.value = versionList.value.find(
(item: any) => item.value === detail.options?.version,
).value;
// image
referImageUrl.value = detail.options.referImageUrl;
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词+动词+风格的格式使用隔开.</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="mt-[15px] w-full"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="mt-8 flex flex-col">
<div><b>随机热词</b></div>
<Space wrap class="mt-4 flex flex-wrap gap-2">
<Button
shape="round"
class="m-0"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="mt-8 w-full">
<div><b>尺寸</b></div>
<Space wrap class="mt-5 flex w-full flex-row justify-between">
<div
class="flex cursor-pointer flex-col items-center"
v-for="imageSize in MidjourneySizeList"
:key="imageSize.key"
@click="handleSizeClick(imageSize)"
>
<div
class="flex h-[50px] w-[50px] items-center justify-center rounded-[7px] border bg-white p-1"
:class="[
selectSize === imageSize.key ? 'border-[#1293ff]' : 'border-white',
]"
>
<div :style="imageSize.style"></div>
</div>
<div class="text-sm font-bold text-[#3e3e3e]">{{ imageSize.key }}</div>
</div>
</Space>
</div>
<div class="mt-8">
<div><b>模型</b></div>
<Space wrap class="mt-4 flex flex-wrap gap-4">
<div
v-for="model in MidjourneyModels"
:key="model.key"
class="flex w-[150px] cursor-pointer flex-col items-center overflow-hidden border-[3px]"
:class="[
selectModel === model.key
? 'rounded border-[#1293ff]'
: 'border-transparent',
]"
>
<Image
:preview="false"
:src="model.image"
fit="contain"
@click="handleModelClick(model)"
/>
<div class="text-sm font-bold text-[#3e3e3e]">{{ model.name }}</div>
</div>
</Space>
</div>
<div class="mt-5">
<div><b>版本</b></div>
<Space wrap class="mt-5 w-full">
<Select
v-model:value="selectVersion"
class="!w-[330px]"
clearable
placeholder="请选择版本"
>
<Select.Option
v-for="item in versionList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Space>
</div>
<div class="mt-8">
<div><b>参考图</b></div>
<Space wrap class="mt-4">
<ImageUpload v-model:value="referImageUrl" :show-description="false" />
</Space>
</div>
<div class="mt-[50px] flex justify-center">
<Button
type="primary"
size="large"
shape="round"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>

View File

@ -0,0 +1,298 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { ref } from 'vue';
import { alert, confirm } from '@vben/common-ui';
import {
Button,
InputNumber,
message,
Select,
Space,
Textarea,
} from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
ImageHotEnglishWords,
StableDiffusionClipGuidancePresets,
StableDiffusionSamplers,
StableDiffusionStylePresets,
} from '#/utils/constants';
import { hasChinese } from '#/utils/utils';
//
//
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
//
const drawIn = ref<boolean>(false); //
const selectHotWord = ref<string>(''); //
//
const prompt = ref<string>(''); //
const width = ref<number>(512); //
const height = ref<number>(512); //
const sampler = ref<string>('DDIM'); //
const steps = ref<number>(20); //
const seed = ref<number>(42); //
const scale = ref<number>(7.5); //
const clipGuidancePreset = ref<string>('NONE'); // (clip_guidance_preset) CLIP
const stylePreset = ref<string>('3d-model'); //
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
//
selectHotWord.value = hotWord; //
prompt.value = hotWord; //
};
/** 图片生成 */
const handleGenerateImage = async () => {
// models
const selectModel = 'stable-diffusion-v1-6';
const matchedModel = props.models.find(
(item) =>
item.model === selectModel &&
item.platform === AiPlatformEnum.STABLE_DIFFUSION,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
//
if (hasChinese(prompt.value)) {
alert('暂不支持中文!');
return;
}
await confirm(`确认生成内容?`);
try {
//
drawIn.value = true;
//
emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION);
//
const form = {
modelId: matchedModel.id,
prompt: prompt.value, //
width: width.value, //
height: height.value, //
options: {
seed: seed.value, //
steps: steps.value, //
scale: scale.value, //
sampler: sampler.value, //
clipGuidancePreset: clipGuidancePreset.value, // CLIP
stylePreset: stylePreset.value, //
},
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
//
emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION);
//
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
width.value = detail.width;
height.value = detail.height;
seed.value = detail.options?.seed;
steps.value = detail.options?.steps;
scale.value = detail.options?.scale;
sampler.value = detail.options?.sampler;
clipGuidancePreset.value = detail.options?.clipGuidancePreset;
stylePreset.value = detail.options?.stylePreset;
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词 + 动词 + 风格的格式使用隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="mt-[15px] w-full"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<!-- 热词区域 -->
<div class="mt-[30px] flex flex-col">
<div><b>随机热词</b></div>
<Space wrap class="mt-[15px] flex flex-wrap justify-start">
<Button
shape="round"
class="m-0"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotEnglishWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<!-- 参数项采样方法 -->
<div class="mt-[30px]">
<div><b>采样方法</b></div>
<Space wrap class="mt-[15px] w-full">
<Select
v-model:value="sampler"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionSamplers"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<!-- CLIP -->
<div class="mt-[30px]">
<div><b>CLIP</b></div>
<Space wrap class="mt-[15px] w-full">
<Select
v-model:value="clipGuidancePreset"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionClipGuidancePresets"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<!-- 风格 -->
<div class="mt-[30px]">
<div><b>风格</b></div>
<Space wrap class="mt-[15px] w-full">
<Select
v-model:value="stylePreset"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionStylePresets"
:key="item.key"
:label="item.name"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<!-- 图片尺寸 -->
<div class="mt-[30px]">
<div><b>图片尺寸</b></div>
<Space wrap class="mt-[15px] w-full">
<InputNumber
v-model:value="width"
class="w-[170px]"
placeholder="图片宽度"
/>
<InputNumber
v-model:value="height"
class="w-[170px]"
placeholder="图片高度"
/>
</Space>
</div>
<!-- 迭代步数 -->
<div class="mt-[30px]">
<div><b>迭代步数</b></div>
<Space wrap class="mt-[15px] w-full">
<InputNumber
v-model:value="steps"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<!-- 引导系数 -->
<div class="mt-[30px]">
<div><b>引导系数</b></div>
<Space wrap class="mt-[15px] w-full">
<InputNumber
v-model:value="scale"
type="number"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<!-- 随机因子 -->
<div class="mt-[30px]">
<div><b>随机因子</b></div>
<Space wrap class="mt-[15px] w-full">
<InputNumber
v-model:value="seed"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<!-- 生成按钮 -->
<div class="mt-[50px] flex justify-center">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>

View File

@ -1,28 +1,134 @@
<script lang="ts" setup>
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { nextTick, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Segmented } from 'ant-design-vue';
import { getModelSimpleList } from '#/api/ai/model/model';
import { AiModelTypeEnum, AiPlatformEnum } from '#/utils/constants';
import Common from './components/common/index.vue';
import Dall3 from './components/dall3/index.vue';
import ImageList from './components/ImageList.vue';
import Midjourney from './components/midjourney/index.vue';
import StableDiffusion from './components/stableDiffusion/index.vue';
const imageListRef = ref<any>(); // image ref
const dall3Ref = ref<any>(); // dall3(openai) ref
const midjourneyRef = ref<any>(); // midjourney ref
const stableDiffusionRef = ref<any>(); // stable diffusion ref
const commonRef = ref<any>(); // stable diffusion ref
//
const selectPlatform = ref('common'); //
const platformOptions = [
{
label: '通用',
value: 'common',
},
{
label: 'DALL3 绘画',
value: AiPlatformEnum.OPENAI,
},
{
label: 'MJ 绘画',
value: AiPlatformEnum.MIDJOURNEY,
},
{
label: 'SD 绘图',
value: AiPlatformEnum.STABLE_DIFFUSION,
},
];
const models = ref<AiModelModelApi.ModelVO[]>([]); //
/** 绘画 start */
const handleDrawStart = async () => {};
/** 绘画 complete */
const handleDrawComplete = async () => {
await imageListRef.value.getImageList();
};
/** 重新生成:将画图详情填充到对应平台 */
const handleRegeneration = async (image: AiImageApi.ImageVO) => {
//
selectPlatform.value = image.platform;
// image
await nextTick();
switch (image.platform) {
case AiPlatformEnum.MIDJOURNEY: {
midjourneyRef.value.settingValues(image);
break;
}
case AiPlatformEnum.OPENAI: {
dall3Ref.value.settingValues(image);
break;
}
case AiPlatformEnum.STABLE_DIFFUSION: {
stableDiffusionRef.value.settingValues(image);
break;
}
// No default
}
// TODO @fan other
};
/** 组件挂载的时候 */
onMounted(async () => {
//
models.value = await getModelSimpleList(AiModelTypeEnum.IMAGE);
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<div class="ai-image absolute inset-0 flex h-full w-full flex-row">
<div class="left flex w-[390px] flex-col p-5">
<div class="segmented flex justify-center">
<Segmented
v-model:value="selectPlatform"
:options="platformOptions"
class="bg-[#ececec]"
/>
</div>
<div class="modal-switch-container mt-[30px] h-full overflow-y-auto">
<Common
v-if="selectPlatform === 'common'"
ref="commonRef"
:models="models"
@on-draw-complete="handleDrawComplete"
/>
<Dall3
v-if="selectPlatform === AiPlatformEnum.OPENAI"
ref="dall3Ref"
:models="models"
@on-draw-start="handleDrawStart"
@on-draw-complete="handleDrawComplete"
/>
<Midjourney
v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
ref="midjourneyRef"
:models="models"
/>
<StableDiffusion
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
ref="stableDiffusionRef"
:models="models"
@on-draw-complete="handleDrawComplete"
/>
</div>
</div>
<div class="main flex-1 bg-white">
<ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,146 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSimpleUserList } from '#/api/system/user';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
},
{
fieldName: 'platform',
label: '平台',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.AI_PLATFORM, 'string'),
},
},
{
fieldName: 'status',
label: '绘画状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.AI_IMAGE_STATUS, 'number'),
},
},
{
fieldName: 'publicStatus',
label: '是否发布',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 180,
fixed: 'left',
},
{
title: '图片',
minWidth: 110,
fixed: 'left',
slots: { default: 'picUrl' },
},
{
minWidth: 180,
title: '用户',
slots: { default: 'userId' },
},
{
field: 'platform',
title: '平台',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_PLATFORM },
},
},
{
field: 'model',
title: '模型',
minWidth: 180,
},
{
field: 'status',
title: '绘画状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_IMAGE_STATUS },
},
},
{
minWidth: 100,
title: '是否发布',
slots: { default: 'publicStatus' },
},
{
field: 'prompt',
title: '提示词',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'width',
title: '宽度',
minWidth: 180,
},
{
field: 'height',
title: '高度',
minWidth: 180,
},
{
field: 'errorMessage',
title: '错误信息',
minWidth: 180,
},
{
field: 'taskId',
title: '任务编号',
minWidth: 180,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,135 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiImageApi } from '#/api/ai/image';
import type { SystemUserApi } from '#/api/system/user';
import { Button } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { confirm, DocAlert, Page } from '@vben/common-ui';
import { Image, message, Switch } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteImage, getImagePage, updateImage } from '#/api/ai/image';
import { getSimpleUserList } from '#/api/system/user';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { AiImageStatusEnum } from '#/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
const userList = ref<SystemUserApi.User[]>([]); //
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 删除 */
async function handleDelete(row: AiImageApi.ImageVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteImage(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 修改是否发布 */
const handleUpdatePublicStatusChange = async (row: AiImageApi.ImageVO) => {
try {
//
const text = row.publicStatus ? '公开' : '私有';
await confirm(`确认要"${text}"该图片吗?`).then(async () => {
await updateImage({
id: row.id,
publicStatus: row.publicStatus,
});
onRefresh();
});
} catch {
row.publicStatus = !row.publicStatus;
}
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getImagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiImageApi.ImageVO>,
});
onMounted(async () => {
//
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 绘图创作" url="https://doc.iocoder.cn/ai/image/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/manager/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/manager/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 绘图创作" url="https://doc.iocoder.cn/ai/image/" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction :actions="[]" />
</template>
<template #picUrl="{ row }">
<Image :src="row.picUrl" class="h-80px w-80px" />
</template>
<template #userId="{ row }">
<span>{{
userList.find((item) => item.id === row.userId)?.nickname
}}</span>
</template>
<template #publicStatus="{ row }">
<Switch
v-model:checked="row.publicStatus"
@change="handleUpdatePublicStatusChange(row)"
:disabled="row.status !== AiImageStatusEnum.SUCCESS"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:image:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Input, Pagination } from 'ant-design-vue';
import { getImagePageMy } from '#/api/ai/image';
// TODO @fan loading
const loading = ref(true); //
const list = ref<AiImageApi.ImageVO[]>([]); //
const total = ref(0); //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
publicStatus: true,
prompt: undefined,
});
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const data = await getImagePageMy(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
const debounceGetList = useDebounceFn(getList, 80);
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 初始化 */
onMounted(async () => {
await getList();
});
</script>
<template>
<Page auto-content-height>
<div class="bg-[#fff] p-[20px]">
<!-- TODO @fanSearch 可以换成 Icon 组件么 -->
<Input.Search
v-model="queryParams.prompt"
class="mb-[20px] w-full"
size="large"
placeholder="请输入要搜索的内容"
@keyup.enter="handleQuery"
/>
<div
class="grid gap-[10px] bg-[#fff] shadow-[0_0_10px_rgba(0,0,0,0.1)]"
style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))"
>
<!-- TODO @fan这个图片的风格要不和 ImageCard.vue 界面一致只有卡片没有操作因为看着更有相框的感觉~~~ -->
<div
v-for="item in list"
:key="item.id"
class="relative cursor-pointer overflow-hidden bg-[#f0f0f0] transition-transform duration-300 hover:scale-[1.05]"
>
<img
:src="item.picUrl"
class="block h-auto w-full transition-transform duration-300 hover:scale-[1.1]"
/>
</div>
</div>
<!-- TODO @fan缺少翻页 -->
<!-- 分页 -->
<Pagination
:total="total"
:show-total="(total) => `共 ${total} 条`"
show-quick-jumper
show-size-changer
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="debounceGetList"
@show-size-change="debounceGetList"
class="mt-[20px]"
/>
</div>
</Page>
</template>

View File

@ -0,0 +1,157 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '知识库名称',
rules: 'required',
},
{
fieldName: 'description',
label: '知识库描述',
component: 'Textarea',
componentProps: {
rows: 3,
placeholder: '请输入知识库描述',
},
},
{
component: 'ApiSelect',
fieldName: 'embeddingModelId',
label: '向量模型',
componentProps: {
api: () => getModelSimpleList(AiModelTypeEnum.EMBEDDING),
labelField: 'name',
valueField: 'id',
allowClear: true,
placeholder: '请选择向量模型',
},
rules: 'required',
},
{
fieldName: 'topK',
label: '检索 topK',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索 topK',
class: 'w-full',
min: 0,
max: 10,
},
rules: 'required',
},
{
fieldName: 'similarityThreshold',
label: '检索相似度阈值',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索相似度阈值',
class: 'w-full',
min: 0,
max: 1,
step: 0.01,
precision: 2,
},
rules: 'required',
},
{
fieldName: 'status',
label: '是否启用',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '文件名称',
component: 'Input',
},
{
fieldName: 'status',
label: '是否启用',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '文档编号',
},
{
field: 'name',
title: '文件名称',
},
{
field: 'contentLength',
title: '字符数',
},
{
field: 'tokens',
title: 'Token 数',
},
{
field: 'segmentMaxTokens',
title: '分片最大 Token 数',
},
{
field: 'retrievalCount',
title: '召回次数',
},
{
field: 'status',
title: '是否启用',
slots: { default: 'status' },
},
{
field: 'createTime',
title: '上传时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Progress } from 'ant-design-vue';
import { getKnowledgeSegmentProcessList } from '#/api/ai/knowledge/segment';
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const parent = inject('parent') as any;
const pollingTimer = ref<null | number>(null); // ID
/** 判断文件处理是否完成 */
const isProcessComplete = (file: any) => {
return file.progress === 100;
};
/** 判断所有文件是否都处理完成 */
const allProcessComplete = computed(() => {
return props.modelValue.list.every((file: any) => isProcessComplete(file));
});
/** 完成按钮点击事件处理 */
const handleComplete = () => {
if (parent?.exposed?.handleBack) {
parent.exposed.handleBack();
}
};
/** 获取文件处理进度 */
const getProcessList = async () => {
try {
// 1. API
const documentIds = props.modelValue.list
.filter((item: any) => item.id)
.map((item: any) => item.id);
if (documentIds.length === 0) {
return;
}
const result = await getKnowledgeSegmentProcessList(documentIds);
// 2.1
const updatedList = props.modelValue.list.map((file: any) => {
const processInfo = result.find(
(item: any) => item.documentId === file.id,
);
if (processInfo) {
// / * 100
const progress =
processInfo.embeddingCount && processInfo.count
? Math.floor((processInfo.embeddingCount / processInfo.count) * 100)
: 0;
return {
...file,
progress,
count: processInfo.count || 0,
};
}
return file;
});
// 2.2
emit('update:modelValue', {
...props.modelValue,
list: updatedList,
});
// 3.
if (!updatedList.every((file: any) => isProcessComplete(file))) {
pollingTimer.value = window.setTimeout(getProcessList, 3000);
}
} catch (error) {
//
console.error('获取处理进度失败:', error);
pollingTimer.value = window.setTimeout(getProcessList, 5000);
}
};
/** 组件挂载时开始轮询 */
onMounted(() => {
// 1. 0
const initialList = props.modelValue.list.map((file: any) => ({
...file,
progress: 0,
}));
emit('update:modelValue', {
...props.modelValue,
list: initialList,
});
// 2.
getProcessList();
});
/** 组件卸载前清除轮询 */
onBeforeUnmount(() => {
// 1.
if (pollingTimer.value) {
clearTimeout(pollingTimer.value);
pollingTimer.value = null;
}
});
</script>
<template>
<div>
<!-- 文件处理列表 -->
<div class="mt-[15px] grid grid-cols-1 gap-2">
<div
v-for="(file, index) in modelValue.list"
:key="index"
class="flex items-center rounded-sm border-l-4 border-l-[#409eff] px-[12px] py-[4px] shadow-sm transition-all duration-300 hover:bg-[#ecf5ff]"
>
<!-- 文件图标和名称 -->
<div class="mr-[10px] flex min-w-[200px] items-center">
<IconifyIcon icon="ep:document" class="mr-8px text-[#409eff]" />
<span class="break-all text-[13px] text-[#303133]">{{
file.name
}}</span>
</div>
<!-- 处理进度 -->
<div class="flex-1">
<Progress
:percent="file.progress || 0"
:size="10"
:status="isProcessComplete(file) ? 'success' : 'active'"
/>
</div>
<!-- 分段数量 -->
<div class="ml-[10px] text-[13px] text-[#606266]">
分段数量{{ file.count ? file.count : '-' }}
</div>
</div>
</div>
<!-- 底部完成按钮 -->
<div class="mt-[20px] flex justify-end">
<Button
:type="allProcessComplete ? 'primary' : 'default'"
:disabled="!allProcessComplete"
@click="handleComplete"
>
完成
</Button>
</div>
</div>
</template>

View File

@ -0,0 +1,285 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import { computed, getCurrentInstance, inject, onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Dropdown,
Empty,
Form,
InputNumber,
Menu,
message,
Tooltip,
} from 'ant-design-vue';
import {
createKnowledgeDocumentList,
updateKnowledgeDocument,
} from '#/api/ai/knowledge/document';
import { splitContent } from '#/api/ai/knowledge/segment';
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const parent = inject('parent', null); //
const modelData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
}); //
const splitLoading = ref(false); //
const currentFile = ref<any>(null); //
const submitLoading = ref(false); //
/** 选择文件 */
const selectFile = async (index: number) => {
currentFile.value = modelData.value.list[index];
await splitContentFile(currentFile.value);
};
/** 获取文件分段内容 */
const splitContentFile = async (file: any) => {
if (!file || !file.url) {
message.warning('文件 URL 不存在');
return;
}
splitLoading.value = true;
try {
// Token
file.segments = await splitContent(
file.url,
modelData.value.segmentMaxTokens,
);
} catch (error) {
console.error('获取分段内容失败:', file, error);
} finally {
splitLoading.value = false;
}
};
/** 处理预览分段 */
const handleAutoSegment = async () => {
//
if (
!currentFile.value &&
modelData.value.list &&
modelData.value.list.length > 0
) {
currentFile.value = modelData.value.list[0];
}
//
if (!currentFile.value) {
message.warning('请先选择文件');
return;
}
//
await splitContentFile(currentFile.value);
};
/** 上一步按钮处理 */
const handlePrevStep = () => {
const parentEl = parent || getCurrentInstance()?.parent;
if (parentEl && typeof parentEl.exposed?.goToPrevStep === 'function') {
parentEl.exposed.goToPrevStep();
}
};
/** 保存操作 */
const handleSave = async () => {
//
if (
!currentFile?.value?.segments ||
currentFile.value.segments.length === 0
) {
message.warning('请先预览分段内容');
return;
}
//
submitLoading.value = true;
try {
if (modelData.value.id) {
//
await updateKnowledgeDocument({
id: modelData.value.id,
segmentMaxTokens: modelData.value.segmentMaxTokens,
});
} else {
//
const data = await createKnowledgeDocumentList({
knowledgeId: modelData.value.knowledgeId,
segmentMaxTokens: modelData.value.segmentMaxTokens,
list: modelData.value.list.map((item: any) => ({
name: item.name,
url: item.url,
})),
});
modelData.value.list.forEach((document: any, index: number) => {
document.id = data[index];
});
}
//
const parentEl = parent || getCurrentInstance()?.parent;
if (parentEl && typeof parentEl.exposed?.goToNextStep === 'function') {
parentEl.exposed.goToNextStep();
}
} catch (error: any) {
console.error('保存失败:', modelData.value, error);
} finally {
//
submitLoading.value = false;
}
};
/** 初始化 */
onMounted(async () => {
// segmentMaxTokens
if (!modelData.value.segmentMaxTokens) {
modelData.value.segmentMaxTokens = 500;
}
//
if (
!currentFile.value &&
modelData.value.list &&
modelData.value.list.length > 0
) {
currentFile.value = modelData.value.list[0];
}
//
if (currentFile.value) {
await splitContentFile(currentFile.value);
}
});
</script>
<template>
<div>
<!-- 上部分段设置部分 -->
<div class="mb-[20px]">
<div class="mb-[20px] flex items-center justify-between">
<div class="flex items-center text-[16px] font-bold">
分段设置
<Tooltip placement="top">
<template #title>
系统会自动将文档内容分割成多个段落您可以根据需要调整分段方式和内容
</template>
<IconifyIcon icon="ep:warning" class="ml-[5px] text-gray-400" />
</Tooltip>
</div>
<div>
<Button type="primary" size="small" @click="handleAutoSegment">
预览分段
</Button>
</div>
</div>
<div class="segment-settings mb-[20px]">
<Form :label-col="{ span: 5 }">
<Form.Item label="最大 Token 数">
<InputNumber
v-model:value="modelData.segmentMaxTokens"
:min="1"
:max="2048"
/>
</Form.Item>
</Form>
</div>
</div>
<div class="mb-[10px]">
<div class="mb-[10px] text-[16px] font-bold">分段预览</div>
<!-- 文件选择器 -->
<div class="file-selector mb-[10px]">
<Dropdown
v-if="modelData.list && modelData.list.length > 0"
trigger="click"
>
<div class="flex cursor-pointer items-center">
<IconifyIcon icon="ep:document" class="text-danger mr-[5px]" />
<span>{{ currentFile?.name || '请选择文件' }}</span>
<span
v-if="currentFile?.segments"
class="ml-5px text-[12px] text-gray-500"
>
({{ currentFile.segments.length }}个分片)
</span>
<IconifyIcon icon="ep:arrow-down" class="ml-[5px]" />
</div>
<template #overlay>
<Menu>
<Menu.Item
v-for="(file, index) in modelData.list"
:key="index"
@click="selectFile(index)"
>
{{ file.name }}
<span
v-if="file.segments"
class="ml-[5px] text-[12px] text-gray-500"
>
({{ file.segments.length }}个分片)
</span>
</Menu.Item>
</Menu>
</template>
</Dropdown>
<div v-else class="text-gray-400">暂无上传文件</div>
</div>
<!-- 文件内容预览 -->
<div
class="file-preview max-h-[600px] overflow-y-auto rounded-md bg-gray-50 p-[15px]"
>
<div
v-if="splitLoading"
class="flex items-center justify-center py-[20px]"
>
<IconifyIcon icon="ep:loading" class="is-loading" />
<span class="ml-[10px]">正在加载分段内容...</span>
</div>
<template
v-else-if="
currentFile &&
currentFile.segments &&
currentFile.segments.length > 0
"
>
<div
v-for="(segment, index) in currentFile.segments"
:key="index"
class="mb-[10px]"
>
<div class="mb-[5px] text-[12px] text-gray-500">
分片-{{ index + 1 }} · {{ segment.contentLength || 0 }} 字符数 ·
{{ segment.tokens || 0 }} Token
</div>
<div class="rounded-md bg-white p-[10px]">
{{ segment.content }}
</div>
</div>
</template>
<Empty v-else description="暂无预览内容" />
</div>
</div>
<!-- 添加底部按钮 -->
<div class="mt-[20px] flex justify-between">
<div>
<Button v-if="!modelData.id" @click="handlePrevStep"></Button>
</div>
<div>
<Button type="primary" :loading="submitLoading" @click="handleSave">
保存并处理
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,264 @@
<script setup lang="ts">
import type { UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { PropType } from 'vue';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { computed, getCurrentInstance, inject, onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { Button, Form, message, UploadDragger } from 'ant-design-vue';
import { useUpload } from '#/components/upload/use-upload';
import { generateAcceptedFileTypes } from '#/utils/upload';
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const formRef = ref(); //
const uploadRef = ref(); //
const parent = inject('parent', null); //
const { uploadUrl, httpRequest } = useUpload(); // 使
const fileList = ref<UploadProps['fileList']>([]); //
const uploadingCount = ref(0); //
//
const supportedFileTypes = [
'TXT',
'MARKDOWN',
'MDX',
'PDF',
'HTML',
'XLSX',
'XLS',
'DOC',
'DOCX',
'CSV',
'EML',
'MSG',
'PPTX',
'XML',
'EPUB',
'PPT',
'MD',
'HTM',
];
const allowedExtensions = new Set(
supportedFileTypes.map((ext) => ext.toLowerCase()),
); //
const maxFileSize = 15; // (MB)
// accept
const acceptedFileTypes = computed(() =>
generateAcceptedFileTypes(supportedFileTypes),
);
/** 表单数据 */
const modelData = computed({
get: () => {
return props.modelValue;
},
set: (val) => emit('update:modelValue', val),
});
/** 确保 list 属性存在 */
const ensureListExists = () => {
if (!props.modelValue.list) {
emit('update:modelValue', {
...props.modelValue,
list: [],
});
}
};
/** 是否所有文件都已上传完成 */
const isAllUploaded = computed(() => {
return (
modelData.value.list &&
modelData.value.list.length > 0 &&
uploadingCount.value === 0
);
});
/**
* 上传前检查文件类型和大小
*
* @param file 待上传的文件
* @returns 是否允许上传
*/
const beforeUpload = (file: any) => {
// 1.1
const fileName = file.name.toLowerCase();
const fileExtension = fileName.slice(
Math.max(0, fileName.lastIndexOf('.') + 1),
);
if (!allowedExtensions.has(fileExtension)) {
message.error('不支持的文件类型!');
return false;
}
// 1.2
if (!(file.size / 1024 / 1024 < maxFileSize)) {
message.error(`文件大小不能超过 ${maxFileSize} MB`);
return false;
}
// 2.
uploadingCount.value++;
return true;
};
async function customRequest(info: UploadRequestOption<any>) {
const file = info.file as File;
const name = file?.name;
try {
//
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await httpRequest(info.file as File, progressEvent);
info.onSuccess!(res);
message.success($t('ui.upload.uploadSuccess'));
ensureListExists();
emit('update:modelValue', {
...props.modelValue,
list: [
...props.modelValue.list,
{
name,
url: res,
},
],
});
} catch (error: any) {
console.error(error);
info.onError!(error);
} finally {
uploadingCount.value = Math.max(0, uploadingCount.value - 1);
}
}
/**
* 从列表中移除文件
*
* @param index 要移除的文件索引
*/
const removeFile = (index: number) => {
//
const newList = [...props.modelValue.list];
newList.splice(index, 1);
//
emit('update:modelValue', {
...props.modelValue,
list: newList,
});
};
/** 下一步按钮处理 */
const handleNextStep = () => {
// 1.1
if (!modelData.value.list || modelData.value.list.length === 0) {
message.warning('请上传至少一个文件');
return;
}
// 1.2
if (uploadingCount.value > 0) {
message.warning('请等待所有文件上传完成');
return;
}
// 2. goToNextStep
const parentEl = parent || getCurrentInstance()?.parent;
if (parentEl && typeof parentEl.exposed?.goToNextStep === 'function') {
parentEl.exposed.goToNextStep();
}
};
/** 初始化 */
onMounted(() => {
ensureListExists();
});
</script>
<template>
<Form ref="formRef" :model="modelData" label-width="0" class="mt-[20px]">
<Form.Item class="mb-[20px]">
<div class="w-full">
<div
class="w-full rounded-md border-2 border-dashed border-[#dcdfe6] p-[20px] text-center hover:border-[#409eff]"
>
<UploadDragger
ref="uploadRef"
class="upload-demo"
:action="uploadUrl"
v-model:file-list="fileList"
:accept="acceptedFileTypes"
:show-upload-list="false"
:before-upload="beforeUpload"
:custom-request="customRequest"
:multiple="true"
>
<div class="flex flex-col items-center justify-center py-[20px]">
<IconifyIcon
icon="ep:upload-filled"
class="mb-[10px] text-[48px] text-[#c0c4cc]"
/>
<div class="ant-upload-text text-[16px] text-[#606266]">
拖拽文件至此或者
<em class="cursor-pointer not-italic text-[#409eff]"
>选择文件</em
>
</div>
<div class="ant-upload-tip mt-10px text-[12px] text-[#909399]">
已支持 {{ supportedFileTypes.join('、') }}每个文件不超过
{{ maxFileSize }} MB
</div>
</div>
</UploadDragger>
</div>
<div
v-if="modelData.list && modelData.list.length > 0"
class="mt-[15px] grid grid-cols-1 gap-2"
>
<div
v-for="(file, index) in modelData.list"
:key="index"
class="flex items-center justify-between rounded-sm border-l-4 border-l-[#409eff] px-[12px] py-[4px] shadow-sm transition-all duration-300 hover:bg-[#ecf5ff]"
>
<div class="flex items-center">
<IconifyIcon icon="ep:document" class="mr-[8px] text-[#409eff]" />
<span class="break-all text-[13px] text-[#303133]">{{
file.name
}}</span>
</div>
<Button
danger
type="text"
link
@click="removeFile(index)"
class="ml-2"
>
<IconifyIcon icon="ep:delete" />
</Button>
</div>
</div>
</div>
</Form.Item>
<Form.Item>
<div class="flex w-full justify-end">
<Button
type="primary"
@click="handleNextStep"
:disabled="!isAllUploaded"
>
下一步
</Button>
</div>
</Form.Item>
</Form>
</template>

View File

@ -0,0 +1,197 @@
<script setup lang="ts">
import {
getCurrentInstance,
onBeforeUnmount,
onMounted,
provide,
ref,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ArrowLeft } from '@vben/icons';
import { Card } from 'ant-design-vue';
import { getKnowledgeDocument } from '#/api/ai/knowledge/document';
import ProcessStep from './ProcessStep.vue';
import SplitStep from './SplitStep.vue';
import UploadStep from './UploadStep.vue';
const route = useRoute(); //
const router = useRouter(); //
//
const uploadDocumentRef = ref();
const documentSegmentRef = ref();
const processCompleteRef = ref();
const currentStep = ref(0); //
const steps = [
{ title: '上传文档' },
{ title: '文档分段' },
{ title: '处理并完成' },
];
const formData = ref({
knowledgeId: undefined, //
id: undefined, // (documentId)
segmentMaxTokens: 500, // token
list: [] as Array<{
count?: number; //
id: number; //
name: string; //
process?: number; //
segments: Array<{
content?: string;
contentLength?: number;
tokens?: number;
}>;
url: string; // URL
}>, //
}); //
provide('parent', getCurrentInstance()); // parent 使
const tabs = useTabs();
/** 返回列表页 */
const handleBack = () => {
//
tabs.closeCurrentTab();
// 使 name 'name'+ menuId
router.push({
path: `/ai/knowledge/document`,
query: {
knowledgeId: route.query.knowledgeId,
},
});
};
/** 初始化数据 */
const initData = async () => {
if (route.query.knowledgeId) {
formData.value.knowledgeId = route.query.knowledgeId as any;
}
// ID
const documentId = route.query.id;
if (documentId) {
//
formData.value.id = documentId as any;
const document = await getKnowledgeDocument(documentId as any);
formData.value.segmentMaxTokens = document.segmentMaxTokens;
formData.value.list = [
{
id: document.id,
name: document.name,
url: document.url,
segments: [],
},
];
//
goToNextStep();
}
};
/** 切换到下一步 */
const goToNextStep = () => {
if (currentStep.value < steps.length - 1) {
currentStep.value++;
}
};
/** 切换到上一步 */
const goToPrevStep = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
/** 初始化 */
onMounted(async () => {
await initData();
});
/** 添加组件卸载前的清理代码 */
onBeforeUnmount(() => {
//
uploadDocumentRef.value = null;
documentSegmentRef.value = null;
processCompleteRef.value = null;
});
/** 暴露方法给子组件使用 */
defineExpose({
goToNextStep,
goToPrevStep,
handleBack,
});
</script>
<template>
<Page auto-content-height>
<div class="mx-auto">
<!-- 头部导航栏 -->
<div
class="border-bottom absolute left-0 right-0 top-0 z-10 flex h-[50px] items-center bg-white px-[20px]"
>
<!-- 左侧标题 -->
<div class="flex w-[200px] items-center overflow-hidden">
<ArrowLeft
class="size-5 flex-shrink-0 cursor-pointer"
@click="handleBack"
/>
<span class="ml-10px text-16px truncate">
{{ formData.id ? '编辑知识库文档' : '创建知识库文档' }}
</span>
</div>
<!-- 步骤条 -->
<div class="flex h-full flex-1 items-center justify-center">
<div class="flex h-full w-[400px] items-center justify-between">
<div
v-for="(step, index) in steps"
:key="index"
class="relative mx-[15px] flex h-full cursor-pointer items-center"
:class="[
currentStep === index
? 'border-b-2 border-solid border-blue-500 text-blue-500'
: 'text-gray-500',
]"
>
<div
class="mr-2 flex h-7 w-7 items-center justify-center rounded-full border-2 border-solid text-[15px]"
:class="[
currentStep === index
? 'border-blue-500 bg-blue-500 text-white'
: 'border-gray-300 bg-white text-gray-500',
]"
>
{{ index + 1 }}
</div>
<span class="whitespace-nowrap text-base font-bold">{{
step.title
}}</span>
</div>
</div>
</div>
</div>
<!-- 主体内容 -->
<Card :body-style="{ padding: '10px' }" class="mb-4">
<div class="mt-[50px]">
<!-- 第一步上传文档 -->
<div v-if="currentStep === 0" class="mx-auto w-[560px]">
<UploadStep v-model="formData" ref="uploadDocumentRef" />
</div>
<!-- 第二步文档分段 -->
<div v-if="currentStep === 1" class="mx-auto w-[560px]">
<SplitStep v-model="formData" ref="documentSegmentRef" />
</div>
<!-- 第三步处理并完成 -->
<div v-if="currentStep === 2" class="mx-auto w-[560px]">
<ProcessStep v-model="formData" ref="processCompleteRef" />
</div>
</div>
</Card>
</div>
</Page>
</template>

View File

@ -0,0 +1,193 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeDocumentApi } from '#/api/ai/knowledge/document';
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { confirm, Page } from '@vben/common-ui';
import { message, Switch } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteKnowledgeDocument,
getKnowledgeDocumentPage,
updateKnowledgeDocumentStatus,
} from '#/api/ai/knowledge/document';
import { $t } from '#/locales';
import { CommonStatusEnum } from '#/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
/** AI 知识库文档 列表 */
defineOptions({ name: 'AiKnowledgeDocument' });
const { hasAccessByCodes } = useAccess();
const route = useRoute(); //
const router = useRouter(); //
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
router.push({
name: 'AiKnowledgeDocumentCreate',
query: { knowledgeId: route.query.knowledgeId },
});
}
/** 编辑 */
function handleEdit(id: number) {
router.push({
name: 'AiKnowledgeDocumentUpdate',
query: { id, knowledgeId: route.query.knowledgeId },
});
}
/** 删除 */
async function handleDelete(row: AiKnowledgeDocumentApi.KnowledgeDocumentVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteKnowledgeDocument(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 跳转到知识库分段页面 */
const handleSegment = (id: number) => {
router.push({
name: 'AiKnowledgeSegment',
query: { documentId: id },
});
};
/** 修改是否发布 */
const handleStatusChange = async (
row: AiKnowledgeDocumentApi.KnowledgeDocumentVO,
) => {
try {
//
const text = row.status ? '启用' : '禁用';
await confirm(`确认要"${text}"${row.name}文档吗?`).then(async () => {
await updateKnowledgeDocumentStatus({
id: row.id,
status: row.status,
});
onRefresh();
});
} catch {
row.status =
row.status === CommonStatusEnum.ENABLE
? CommonStatusEnum.DISABLE
: CommonStatusEnum.ENABLE;
}
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getKnowledgeDocumentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
knowledgeId: route.query.knowledgeId,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiKnowledgeDocumentApi.KnowledgeDocumentVO>,
});
/** 初始化 */
onMounted(() => {
// ID
if (!route.query.knowledgeId) {
message.error('知识库 ID 不存在,无法查看文档列表');
//
router.back();
}
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['知识库文档']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:knowledge:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #status="{ row }">
<Switch
v-model:checked="row.status"
:checked-value="0"
:un-checked-value="1"
@change="handleStatusChange(row)"
:disabled="!hasAccessByCodes(['ai:knowledge:update'])"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:knowledge:update'],
onClick: handleEdit.bind(null, row.id),
},
]"
:drop-down-actions="[
{
label: '分段',
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleSegment.bind(null, row.id),
},
{
label: $t('common.delete'),
type: 'link',
auth: ['ai:knowledge:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,162 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '知识库名称',
rules: 'required',
},
{
fieldName: 'description',
label: '知识库描述',
component: 'Textarea',
componentProps: {
rows: 3,
placeholder: '请输入知识库描述',
},
},
{
component: 'ApiSelect',
fieldName: 'embeddingModelId',
label: '向量模型',
componentProps: {
api: () => getModelSimpleList(AiModelTypeEnum.EMBEDDING),
labelField: 'name',
valueField: 'id',
allowClear: true,
placeholder: '请选择向量模型',
},
rules: 'required',
},
{
fieldName: 'topK',
label: '检索 topK',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索 topK',
class: 'w-full',
min: 0,
max: 10,
},
rules: 'required',
},
{
fieldName: 'similarityThreshold',
label: '检索相似度阈值',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索相似度阈值',
class: 'w-full',
min: 0,
max: 1,
step: 0.01,
precision: 2,
},
rules: 'required',
},
{
fieldName: 'status',
label: '是否启用',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '知识库名称',
component: 'Input',
},
{
fieldName: 'status',
label: '是否启用',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
},
{
field: 'name',
title: '知识库名称',
},
{
field: 'description',
title: '知识库描述',
},
{
field: 'embeddingModel',
title: '向量化模型',
},
{
field: 'status',
title: '是否启用',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,162 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeKnowledgeApi } from '#/api/ai/knowledge/knowledge';
import { Button } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteKnowledge,
getKnowledgePage,
} from '#/api/ai/knowledge/knowledge';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 */
function handleEdit(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteKnowledge(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 文档按钮操作 */
const router = useRouter();
const handleDocument = (id: number) => {
router.push({
name: 'AiKnowledgeDocument',
query: { knowledgeId: id },
});
};
/** 跳转到文档召回测试页面 */
const handleRetrieval = (id: number) => {
router.push({
name: 'AiKnowledgeRetrieval',
query: { id },
});
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getKnowledgePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiKnowledgeKnowledgeApi.KnowledgeVO>,
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 知识库" url="https://doc.iocoder.cn/ai/knowledge/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
<FormModal @success="onRefresh" />
<Grid table-title="AI ">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['AI 知识库']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:knowledge:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:knowledge:update'],
onClick: handleEdit.bind(null, row),
},
]"
:drop-down-actions="[
{
label: $t('ui.widgets.document'),
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleDocument.bind(null, row.id),
},
{
label: '召回测试',
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleRetrieval.bind(null, row.id),
},
{
label: $t('common.delete'),
type: 'link',
auth: ['ai:knowledge:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { AiKnowledgeKnowledgeApi } from '#/api/ai/knowledge/knowledge';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createKnowledge,
getKnowledge,
updateKnowledge,
} from '#/api/ai/knowledge/knowledge';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiKnowledgeKnowledgeApi.KnowledgeVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['AI 知识库'])
: $t('ui.actionTitle.create', ['AI 知识库']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as AiKnowledgeKnowledgeApi.KnowledgeVO;
try {
await (formData.value?.id
? updateKnowledge(data)
: createKnowledge(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiKnowledgeKnowledgeApi.KnowledgeVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getKnowledge(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,210 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Empty,
InputNumber,
message,
Textarea,
} from 'ant-design-vue';
import { getKnowledge } from '#/api/ai/knowledge/knowledge';
import { searchKnowledgeSegment } from '#/api/ai/knowledge/segment';
/** 文档召回测试 */
defineOptions({ name: 'KnowledgeDocumentRetrieval' });
const route = useRoute(); //
const router = useRouter(); //
const loading = ref(false); //
const segments = ref<any[]>([]); //
const queryParams = reactive({
id: undefined,
content: '',
topK: 10,
similarityThreshold: 0.5,
});
/** 调用文档召回测试接口 */
const getRetrievalResult = async () => {
if (!queryParams.content) {
message.warning('请输入查询文本');
return;
}
loading.value = true;
segments.value = [];
try {
const data = await searchKnowledgeSegment({
knowledgeId: queryParams.id,
content: queryParams.content,
topK: queryParams.topK,
similarityThreshold: queryParams.similarityThreshold,
});
segments.value = data || [];
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
/** 展开/收起段落内容 */
const toggleExpand = (segment: any) => {
segment.expanded = !segment.expanded;
};
/** 获取知识库信息 */
const getKnowledgeInfo = async (id: number) => {
try {
const knowledge = await getKnowledge(id);
if (knowledge) {
queryParams.topK = knowledge.topK || queryParams.topK;
queryParams.similarityThreshold =
knowledge.similarityThreshold || queryParams.similarityThreshold;
}
} catch {}
};
/** 初始化 */
onMounted(() => {
// ID
if (!route.query.id) {
message.error('知识库 ID 不存在,无法进行召回测试');
router.back();
return;
}
queryParams.id = route.query.id as any;
//
getKnowledgeInfo(queryParams.id as any);
});
</script>
<template>
<Page auto-content-height>
<div class="flex w-full gap-4">
<Card class="min-w-300 flex-1">
<div class="mb-15">
<h3
class="m-0 mb-2 font-semibold leading-none tracking-tight"
style="font-size: 18px"
>
召回测试
</h3>
<div class="text-14 text-gray-500">
根据给定的查询文本测试召回效果
</div>
</div>
<div>
<div class="relative mb-2">
<Textarea
v-model:value="queryParams.content"
:rows="8"
placeholder="请输入文本"
/>
<div class="text-12 absolute bottom-2 right-2 text-gray-400">
{{ queryParams.content?.length }} / 200
</div>
</div>
<div class="mb-2 flex items-center">
<span class="w-16 text-gray-500">topK:</span>
<InputNumber
v-model:value="queryParams.topK"
:min="1"
:max="20"
class="w-full"
/>
</div>
<div class="mb-2 flex items-center">
<span class="w-16 text-gray-500">相似度:</span>
<InputNumber
v-model:value="queryParams.similarityThreshold"
class="w-full"
:min="0"
:max="1"
:precision="2"
:step="0.01"
/>
</div>
<div class="flex justify-end">
<Button
type="primary"
@click="getRetrievalResult"
:loading="loading"
>
测试
</Button>
</div>
</div>
</Card>
<Card class="min-w-300 flex-1">
<!-- 加载中状态 -->
<template v-if="loading">
<div class="flex h-[300px] items-center justify-center">
<Empty description="正在检索中..." />
</div>
</template>
<!-- 有段落 -->
<template v-else-if="segments.length > 0">
<div class="mb-15 font-bold">{{ segments.length }} 个召回段落</div>
<div>
<div
v-for="(segment, index) in segments"
:key="index"
class="p-15 mb-20 rounded border border-solid border-gray-200"
>
<div class="text-12 mb-5 flex justify-between text-gray-500">
<span>
分段({{ segment.id }}) · {{ segment.contentLength }} 字符数 ·
{{ segment.tokens }} Token
</span>
<span
class="-8 text-12 rounded-full bg-blue-50 py-4 font-bold text-blue-500"
>
score: {{ segment.score }}
</span>
</div>
<div
class="text-13 mb-10 overflow-hidden whitespace-pre-wrap rounded bg-gray-50 p-10 transition-all duration-100"
:class="{
'max-h-50 line-clamp-2': !segment.expanded,
'max-h-500': segment.expanded,
}"
>
{{ segment.content }}
</div>
<div class="flex items-center justify-between">
<div class="text-13 flex items-center text-gray-500">
<span class="ep:document mr-5"></span>
<span>{{ segment.documentName || '未知文档' }}</span>
</div>
<Button size="small" @click="toggleExpand(segment)">
{{ segment.expanded ? '收起' : '展开' }}
<span
class="mr-5"
:class="segment.expanded ? 'ep:arrow-up' : 'ep:arrow-down'"
></span>
</Button>
</div>
</div>
</div>
</template>
<!-- 无召回结果 -->
<template v-else>
<div class="flex h-[300px] items-center justify-center">
<Empty description="暂无召回结果" />
</div>
</template>
</Card>
</div>
</Page>
</template>

View File

@ -0,0 +1,103 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'documentId',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'content',
label: '切片内容',
component: 'Textarea',
componentProps: {
placeholder: '请输入切片内容',
rows: 6,
showCount: true,
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'documentId',
label: '文档编号',
component: 'Input',
},
{
fieldName: 'status',
label: '是否启用',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '分段编号',
},
{
type: 'expand',
width: 40,
slots: { content: 'expand_content' },
},
{
field: 'content',
title: '切片内容',
minWidth: 250,
},
{
field: 'contentLength',
title: '字符数',
},
{
field: 'tokens',
title: 'token 数量',
},
{
field: 'retrievalCount',
title: '召回次数',
},
{
field: 'status',
title: '是否启用',
slots: { default: 'status' },
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,199 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeKnowledgeApi } from '#/api/ai/knowledge/knowledge';
import type { AiKnowledgeSegmentApi } from '#/api/ai/knowledge/segment';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAccess } from '@vben/access';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { message, Switch } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteKnowledgeSegment,
getKnowledgeSegmentPage,
updateKnowledgeSegmentStatus,
} from '#/api/ai/knowledge/segment';
import { $t } from '#/locales';
import { CommonStatusEnum } from '#/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const route = useRoute();
const { hasAccessByCodes } = useAccess();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData({ documentId: route.query.documentId }).open();
}
/** 编辑 */
function handleEdit(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteKnowledgeSegment(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getKnowledgeSegmentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiKnowledgeKnowledgeApi.KnowledgeVO>,
});
/** 修改是否发布 */
const handleStatusChange = async (
row: AiKnowledgeSegmentApi.KnowledgeSegmentVO,
) => {
try {
//
const text = row.status ? '启用' : '禁用';
await confirm(`确认要"${text}"该分段吗?`).then(async () => {
await updateKnowledgeSegmentStatus({
id: row.id,
status: row.status,
});
gridApi.reload();
});
} catch {
row.status =
row.status === CommonStatusEnum.ENABLE
? CommonStatusEnum.DISABLE
: CommonStatusEnum.ENABLE;
}
};
onMounted(() => {
gridApi.formApi.setFieldValue('documentId', route.query.documentId);
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['分段']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:knowledge:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #status="{ row }">
<Switch
v-model:checked="row.status"
:checked-value="0"
:un-checked-value="1"
@change="handleStatusChange(row)"
:disabled="!hasAccessByCodes(['ai:knowledge:update'])"
/>
</template>
<template #expand_content="{ row }">
<div
class="content-expand"
style="
padding: 10px 20px;
line-height: 1.5;
white-space: pre-wrap;
background-color: #f9f9f9;
border-left: 3px solid #409eff;
border-radius: 4px;
"
>
<div
class="content-title"
style="
margin-bottom: 8px;
font-size: 14px;
font-weight: bold;
color: #606266;
"
>
完整内容
</div>
{{ row.content }}
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:knowledge:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:knowledge:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { AiKnowledgeSegmentApi } from '#/api/ai/knowledge/segment';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createKnowledgeSegment,
getKnowledgeSegment,
updateKnowledgeSegment,
} from '#/api/ai/knowledge/segment';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiKnowledgeSegmentApi.KnowledgeSegmentVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['分段'])
: $t('ui.actionTitle.create', ['分段']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as AiKnowledgeSegmentApi.KnowledgeSegmentVO;
try {
await (formData.value?.id
? updateKnowledgeSegment(data)
: createKnowledgeSegment(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiKnowledgeSegmentApi.KnowledgeSegmentVO>();
if (!data || !data.id) {
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getKnowledgeSegment(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -1,28 +1,94 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { AiMindmapApi } from '#/api/ai/mindmap';
import { Button } from 'ant-design-vue';
import { nextTick, onMounted, ref } from 'vue';
import { alert, Page } from '@vben/common-ui';
import { generateMindMap } from '#/api/ai/mindmap';
import { MindMapContentExample } from '#/utils/constants';
import Left from './modules/Left.vue';
import Right from './modules/Right.vue';
const ctrl = ref<AbortController>(); //
const isGenerating = ref(false); //
const isStart = ref(false); //
const isEnd = ref(true); //
const generatedContent = ref(''); //
const leftRef = ref<InstanceType<typeof Left>>(); //
const rightRef = ref(); //
/** 使用已有内容直接生成 */
const directGenerate = (existPrompt: string) => {
isEnd.value = false; // false true watch
generatedContent.value = existPrompt;
isEnd.value = true;
};
/** 提交生成 */
const submit = (data: AiMindmapApi.AiMindMapGenerateReqVO) => {
isGenerating.value = true;
isStart.value = true;
isEnd.value = false;
ctrl.value = new AbortController(); //
generatedContent.value = ''; //
generateMindMap({
data,
onMessage: async (res: any) => {
const { code, data, msg } = JSON.parse(res.data);
if (code !== 0) {
alert(`生成思维导图异常! ${msg}`);
stopStream();
return;
}
generatedContent.value = generatedContent.value + data;
await nextTick();
rightRef.value?.scrollBottom();
},
onClose() {
isEnd.value = true;
leftRef.value?.setGeneratedContent(generatedContent.value);
stopStream();
},
onError(err) {
console.error('生成思维导图失败', err);
stopStream();
//
throw err;
},
ctrl: ctrl.value,
});
};
/** 停止 stream 生成 */
const stopStream = () => {
isGenerating.value = false;
isStart.value = false;
ctrl.value?.abort();
};
/** 初始化 */
onMounted(() => {
generatedContent.value = MindMapContentExample;
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/mindmap/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/mindmap/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<div class="absolute bottom-0 left-0 right-0 top-0 flex">
<Left
ref="leftRef"
:is-generating="isGenerating"
@submit="submit"
@direct-generate="directGenerate"
/>
<Right
ref="rightRef"
:generated-content="generatedContent"
:is-end="isEnd"
:is-generating="isGenerating"
:is-start="isStart"
/>
</div>
</Page>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Button, Textarea } from 'ant-design-vue';
import { MindMapContentExample } from '#/utils/constants';
defineProps<{
isGenerating: boolean;
}>();
const emits = defineEmits(['submit', 'directGenerate']);
const formData = reactive({
prompt: '',
});
const generatedContent = ref(MindMapContentExample); //
defineExpose({
setGeneratedContent(newContent: string) {
//
generatedContent.value = newContent;
},
});
</script>
<template>
<div class="flex w-[350px] flex-col bg-[#f5f7f9] p-5">
<h3
class="h-[1.75rem] w-full text-center text-[1.25rem] leading-[28px] text-[hsl(var(--primary))]"
>
思维导图创作中心
</h3>
<div class="flex-grow overflow-y-auto">
<div>
<b>您的需求</b>
<Textarea
v-model:value="formData.prompt"
:maxlength="1024"
:rows="8"
class="w-100% mt-15px"
placeholder="请输入提示词让AI帮你完善"
show-count
/>
<Button
class="mt-[15px] !w-full"
type="primary"
:loading="isGenerating"
@click="emits('submit', formData)"
>
智能生成思维导图
</Button>
</div>
<div class="mt-[30px]">
<b>使用已有内容生成</b>
<Textarea
v-model:value="generatedContent"
:maxlength="1024"
:rows="8"
class="w-100% mt-15px"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
<Button
class="mt-[15px] !w-full"
type="primary"
@click="emits('directGenerate', generatedContent)"
:disabled="isGenerating"
>
直接生成
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,202 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
MarkdownIt,
Markmap,
Toolbar,
Transformer,
} from '@vben/plugins/markmap';
import { Button, Card, message } from 'ant-design-vue';
import { download } from '#/utils/download';
const props = defineProps<{
generatedContent: string; //
isEnd: boolean; //
isGenerating: boolean; //
isStart: boolean; // html
}>();
const md = MarkdownIt();
const contentRef = ref<HTMLDivElement>(); // header
const mdContainerRef = ref<HTMLDivElement>(); // markdown
const mindMapRef = ref<HTMLDivElement>(); //
const svgRef = ref<SVGElement>(); // svg
const toolBarRef = ref<HTMLDivElement>(); //
const html = ref(''); //
const contentAreaHeight = ref(0); // header
let markMap: Markmap | null = null;
const transformer = new Transformer();
let resizeObserver: null | ResizeObserver = null;
const initialized = false;
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
contentAreaHeight.value = contentRef.value?.clientHeight || 0;
//
if (contentAreaHeight.value && !initialized) {
/** 初始化思维导图 */
try {
if (!markMap) {
markMap = Markmap.create(svgRef.value!);
const { el } = Toolbar.create(markMap);
toolBarRef.value?.append(el);
}
nextTick(update);
} catch {
message.error('思维导图初始化失败');
}
}
});
if (contentRef.value) {
resizeObserver.observe(contentRef.value);
}
});
onBeforeUnmount(() => {
if (resizeObserver && contentRef.value) {
resizeObserver.unobserve(contentRef.value);
}
});
watch(props, ({ generatedContent, isGenerating, isEnd, isStart }) => {
// markdown
if (isStart) {
html.value = '';
}
// 使 markdown
if (isGenerating) {
html.value = md.render(generatedContent);
}
//
if (isEnd) {
update();
}
});
/** 更新思维导图的展示 */
const update = () => {
try {
const { root } = transformer.transform(
processContent(props.generatedContent),
);
markMap?.setData(root);
markMap?.fit();
} catch (error: any) {
console.error(error);
}
};
/** 处理内容 */
const processContent = (text: string) => {
const arr: string[] = [];
const lines = text.split('\n');
for (let line of lines) {
if (line.includes('```')) {
continue;
}
// eslint-disable-next-line unicorn/prefer-string-replace-all
line = line.replace(/([*_~`>])|(\d+\.)\s/g, '');
arr.push(line);
}
return arr.join('\n');
};
/** 下载图片download SVG to png file */
const downloadImage = () => {
const svgElement = mindMapRef.value;
// SVG
const serializer = new XMLSerializer();
const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgRef.value!)}`;
const base64Url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
download.image({
url: base64Url,
canvasWidth: svgElement?.offsetWidth,
canvasHeight: svgElement?.offsetHeight,
drawWithImageSize: false,
});
};
defineExpose({
scrollBottom() {
mdContainerRef.value?.scrollTo(0, mdContainerRef.value?.scrollHeight);
},
});
</script>
<template>
<Card class="my-card flex h-full flex-grow flex-col">
<template #title>
<div class="m-0 flex shrink-0 items-center justify-between px-7">
<h3>思维导图预览</h3>
<Button type="primary" size="small" class="flex" @click="downloadImage">
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--copy-twotone]"></span>
</div>
</template>
下载图片
</Button>
</div>
</template>
<div ref="contentRef" class="hide-scroll-bar box-border h-full">
<!--展示 markdown 的容器最终生成的是 html 字符串直接用 v-html 嵌入-->
<div
v-if="isGenerating"
ref="mdContainerRef"
class="wh-full overflow-y-auto"
>
<div
class="flex flex-col items-center justify-center"
v-html="html"
></div>
</div>
<div ref="mindMapRef" class="wh-full">
<svg
ref="svgRef"
:style="{ height: `${contentAreaHeight}px` }"
class="w-full"
/>
<div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div>
</div>
</div>
</Card>
</template>
<style lang="scss" scoped>
// mixin extend
@mixin hide-scroll-bar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.hide-scroll-bar {
@include hide-scroll-bar;
}
.my-card {
:deep(.ant-card-body) {
box-sizing: border-box;
flex-grow: 1;
padding: 0;
overflow-y: auto;
@include hide-scroll-bar;
}
}
// markmaptool
:deep(.markmap) {
width: 100%;
}
:deep(.mm-toolbar-brand) {
display: none;
}
:deep(.mm-toolbar) {
display: flex;
flex-direction: row;
}
</style>

View File

@ -0,0 +1,84 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSimpleUserList } from '#/api/system/user';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
},
{
fieldName: 'prompt',
label: '提示词',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 180,
fixed: 'left',
},
{
minWidth: 180,
title: '用户',
slots: { default: 'userId' },
},
{
field: 'prompt',
title: '提示词',
minWidth: 180,
},
{
field: 'generatedContent',
title: '思维导图',
minWidth: 300,
},
{
field: 'model',
title: '模型',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'errorMessage',
title: '错误信息',
minWidth: 180,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,139 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiMindmapApi } from '#/api/ai/mindmap';
import type { SystemUserApi } from '#/api/system/user';
import { Button } from 'ant-design-vue';
import { nextTick, onMounted, ref } from 'vue';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMindMap, getMindMapPage } from '#/api/ai/mindmap';
import { getSimpleUserList } from '#/api/system/user';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import Right from '../index/modules/Right.vue';
import { useGridColumns, useGridFormSchema } from './data';
const userList = ref<SystemUserApi.User[]>([]); //
const previewVisible = ref(false); // drawer
const previewContent = ref('');
const [Drawer, drawerApi] = useVbenDrawer({
header: false,
footer: false,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 删除 */
async function handleDelete(row: AiMindmapApi.MindMapVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
key: 'action_key_msg',
});
try {
await deleteMindMap(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMindMapPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiMindmapApi.MindMapVO>,
});
const openPreview = async (row: AiMindmapApi.MindMapVO) => {
previewVisible.value = false;
drawerApi.open();
await nextTick();
previewVisible.value = true;
previewContent.value = row.generatedContent;
};
onMounted(async () => {
//
userList.value = await getSimpleUserList();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 思维导图" url="https://doc.iocoder.cn/ai/mindmap/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/mindmap/manager/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/mindmap/manager/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 思维导图" url="https://doc.iocoder.cn/ai/mindmap/" />
<Drawer class="w-[800px]">
<Right
v-if="previewVisible"
:generated-content="previewContent"
:is-end="true"
:is-generating="false"
:is-start="false"
/>
</Drawer>
<Grid table-title="">
<template #toolbar-tools>
<TableAction :actions="[]" />
</template>
<template #userId="{ row }">
<span>{{
userList.find((item) => item.id === row.userId)?.nickname
}}</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('ui.cropper.preview'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:api-key:update'],
onClick: openPreview.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:mind-map:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,126 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'platform',
label: '所属平台',
component: 'Select',
componentProps: {
placeholder: '请选择所属平台',
options: getDictOptions(DICT_TYPE.AI_PLATFORM, 'string'),
allowClear: true,
},
rules: z.string().min(1, { message: '请输入平台' }),
},
{
component: 'Input',
fieldName: 'name',
label: '名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'apiKey',
label: '密钥',
rules: 'required',
},
{
component: 'Input',
fieldName: 'url',
label: '自定义 API URL',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名称',
component: 'Input',
},
{
fieldName: 'platform',
label: '平台',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.AI_PLATFORM, 'string'),
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'platform',
title: '所属平台',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_PLATFORM },
},
},
{
field: 'name',
title: '名称',
},
{
field: 'apiKey',
title: '密钥',
},
{
field: 'url',
title: '自定义 API URL',
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,129 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelApiKeyApi } from '#/api/ai/model/apiKey';
import { Button } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteApiKey, getApiKeyPage } from '#/api/ai/model/apiKey';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 */
function handleEdit(row: AiModelApiKeyApi.ApiKeyVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiModelApiKeyApi.ApiKeyVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteApiKey(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiKeyPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiModelApiKeyApi.ApiKeyVO>,
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/apiKey/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/apiKey/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
<FormModal @success="onRefresh" />
<Grid table-title="API ">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['API 密钥']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:api-key:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:api-key:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:api-key:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { AiModelApiKeyApi } from '#/api/ai/model/apiKey';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createApiKey, getApiKey, updateApiKey } from '#/api/ai/model/apiKey';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiModelApiKeyApi.ApiKeyVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['API 密钥'])
: $t('ui.actionTitle.create', ['API 密钥']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as AiModelApiKeyApi.ApiKeyVO;
try {
await (formData.value?.id ? updateApiKey(data) : createApiKey(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiModelApiKeyApi.ApiKeyVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getApiKey(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,277 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getSimpleKnowledgeList } from '#/api/ai/knowledge/knowledge';
import { getModelSimpleList } from '#/api/ai/model/model';
import { getToolSimpleList } from '#/api/ai/model/tool';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'formType',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '角色名称',
rules: 'required',
},
{
component: 'ImageUpload',
fieldName: 'avatar',
label: '角色头像',
componentProps: {
maxSize: 1,
},
rules: 'required',
},
{
fieldName: 'modelId',
label: '绑定模型',
component: 'ApiSelect',
componentProps: {
placeholder: '请选择绑定模型',
api: () => getModelSimpleList(AiModelTypeEnum.CHAT),
labelField: 'name',
valueField: 'id',
allowClear: true,
},
dependencies: {
triggerFields: ['formType'],
show: (values) => {
return values.formType === 'create' || values.formType === 'update';
},
},
},
{
component: 'Input',
fieldName: 'category',
label: '角色类别',
rules: 'required',
dependencies: {
triggerFields: ['formType'],
show: (values) => {
return values.formType === 'create' || values.formType === 'update';
},
},
},
{
component: 'Textarea',
fieldName: 'description',
label: '角色描述',
componentProps: {
placeholder: '请输入角色描述',
},
rules: 'required',
},
{
fieldName: 'systemMessage',
label: '角色设定',
component: 'Textarea',
componentProps: {
placeholder: '请输入角色设定',
},
rules: 'required',
},
{
fieldName: 'knowledgeIds',
label: '引用知识库',
component: 'ApiSelect',
componentProps: {
placeholder: '请选择引用知识库',
api: getSimpleKnowledgeList,
labelField: 'name',
mode: 'multiple',
valueField: 'id',
allowClear: true,
},
},
{
fieldName: 'toolIds',
label: '引用工具',
component: 'ApiSelect',
componentProps: {
placeholder: '请选择引用工具',
api: getToolSimpleList,
mode: 'multiple',
labelField: 'name',
valueField: 'id',
allowClear: true,
},
},
{
fieldName: 'publicStatus',
label: '是否公开',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
defaultValue: true,
dependencies: {
triggerFields: ['formType'],
show: (values) => {
return values.formType === 'create' || values.formType === 'update';
},
},
rules: 'required',
},
{
fieldName: 'sort',
label: '角色排序',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入角色排序',
class: 'w-full',
},
dependencies: {
triggerFields: ['formType'],
show: (values) => {
return values.formType === 'create' || values.formType === 'update';
},
},
rules: 'required',
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
dependencies: {
triggerFields: ['formType'],
show: (values) => {
return values.formType === 'create' || values.formType === 'update';
},
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
},
{
fieldName: 'category',
label: '角色类别',
component: 'Input',
},
{
fieldName: 'publicStatus',
label: '是否公开',
component: 'Select',
componentProps: {
placeholder: '请选择是否公开',
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
allowClear: true,
},
defaultValue: true,
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '角色名称',
minWidth: 100,
},
{
title: '绑定模型',
field: 'modelName',
minWidth: 100,
},
{
title: '角色头像',
slots: { default: 'avatar' },
minWidth: 140,
},
{
title: '角色类别',
field: 'category',
minWidth: 100,
},
{
title: '角色描述',
field: 'description',
minWidth: 100,
},
{
title: '角色设定',
field: 'systemMessage',
minWidth: 100,
},
{
title: '知识库',
slots: { default: 'knowledgeIds' },
minWidth: 100,
},
{
title: '工具',
slots: { default: 'toolIds' },
minWidth: 100,
},
{
field: 'publicStatus',
title: '是否公开',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
minWidth: 80,
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
minWidth: 80,
},
{
title: '角色排序',
field: 'sort',
minWidth: 80,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,140 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
import { Button } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteChatRole, getChatRolePage } from '#/api/ai/model/chatRole';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData({ formType: 'create' }).open();
}
/** 编辑 */
function handleEdit(row: AiModelChatRoleApi.ChatRoleVO) {
formModalApi.setData({ formType: 'update', ...row }).open();
}
/** 删除 */
async function handleDelete(row: AiModelChatRoleApi.ChatRoleVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteChatRole(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getChatRolePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiModelChatRoleApi.ChatRoleVO>,
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 对话聊天" url="https://doc.iocoder.cn/ai/chat/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/chatRole/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/chatRole/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 对话聊天" url="https://doc.iocoder.cn/ai/chat/" />
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['聊天角色']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:chat-role:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #knowledgeIds="{ row }">
<span v-if="!row.knowledgeIds || row.knowledgeIds.length === 0">-</span>
<span v-else> {{ row.knowledgeIds.length }} </span>
</template>
<template #toolIds="{ row }">
<span v-if="!row.toolIds || row.toolIds.length === 0">-</span>
<span v-else> {{ row.toolIds.length }} </span>
</template>
<template #avatar="{ row }">
<Image :src="row.avatar" class="w-32px h-32px" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:chat-role:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:chat-role:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createChatRole,
getChatRole,
updateChatRole,
} from '#/api/ai/model/chatRole';
import {} from '#/api/bpm/model';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiModelChatRoleApi.ChatRoleVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['聊天角色'])
: $t('ui.actionTitle.create', ['聊天角色']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as AiModelChatRoleApi.ChatRoleVO;
try {
await (formData.value?.id ? updateChatRole(data) : createChatRole(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiModelChatRoleApi.ChatRoleVO>();
if (!data || !data.id) {
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getChatRole(data.id as number);
// values
await formApi.setValues({ ...data, ...formData.value });
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,248 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getApiKeySimpleList } from '#/api/ai/model/apiKey';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'platform',
label: '所属平台',
component: 'Select',
componentProps: {
placeholder: '请选择所属平台',
options: getDictOptions(DICT_TYPE.AI_PLATFORM, 'string'),
allowClear: true,
},
rules: z.string().min(1, { message: '请输入平台' }),
},
{
fieldName: 'type',
label: '模型类型',
component: 'Select',
componentProps: (values) => {
return {
placeholder: '请输入模型类型',
disabled: !!values.id,
options: getDictOptions(DICT_TYPE.AI_MODEL_TYPE, 'number'),
allowClear: true,
};
},
rules: 'required',
},
{
fieldName: 'keyId',
label: 'API 秘钥',
component: 'ApiSelect',
componentProps: {
placeholder: '请选择API 秘钥',
api: getApiKeySimpleList,
labelField: 'name',
valueField: 'id',
allowClear: true,
},
rules: 'required',
},
{
component: 'Input',
fieldName: 'name',
label: '模型名字',
rules: 'required',
},
{
component: 'Input',
fieldName: 'model',
label: '模型标识',
rules: 'required',
},
{
fieldName: 'sort',
label: '模型排序',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入模型排序',
class: 'w-full',
},
rules: 'required',
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'temperature',
label: '温度参数',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入温度参数',
class: 'w-full',
min: 0,
max: 2,
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [AiModelTypeEnum.CHAT].includes(values.type);
},
},
rules: 'required',
},
{
fieldName: 'maxTokens',
label: '回复数 Token 数',
component: 'InputNumber',
componentProps: {
min: 0,
max: 8192,
controlsPosition: 'right',
placeholder: '请输入回复数 Token 数',
class: 'w-full',
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [AiModelTypeEnum.CHAT].includes(values.type);
},
},
rules: 'required',
},
{
fieldName: 'maxContexts',
label: '上下文数量',
component: 'InputNumber',
componentProps: {
min: 0,
max: 20,
controlsPosition: 'right',
placeholder: '请输入上下文数量',
class: 'w-full',
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [AiModelTypeEnum.CHAT].includes(values.type);
},
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '模型名字',
component: 'Input',
},
{
fieldName: 'model',
label: '模型标识',
component: 'Input',
},
{
fieldName: 'platform',
label: '模型平台',
component: 'Input',
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'platform',
title: '所属平台',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_PLATFORM },
},
minWidth: 100,
},
{
field: 'type',
title: '模型类型',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.AI_MODEL_TYPE },
},
minWidth: 100,
},
{
field: 'name',
title: '模型名字',
minWidth: 180,
},
{
title: '模型标识',
field: 'model',
minWidth: 180,
},
{
title: 'API 秘钥',
slots: { default: 'keyId' },
minWidth: 140,
},
{
title: '排序',
field: 'sort',
minWidth: 80,
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
minWidth: 80,
},
{
field: 'temperature',
title: '温度参数',
minWidth: 80,
},
{
title: '回复数 Token 数',
field: 'maxTokens',
minWidth: 140,
},
{
title: '上下文数量',
field: 'maxContexts',
minWidth: 100,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,143 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelApiKeyApi } from '#/api/ai/model/apiKey';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { Button } from 'ant-design-vue';
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getApiKeySimpleList } from '#/api/ai/model/apiKey';
import { deleteModel, getModelPage } from '#/api/ai/model/model';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const apiKeyList = ref([] as AiModelApiKeyApi.ApiKeyVO[]);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 */
function handleEdit(row: AiModelModelApi.ModelVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiModelModelApi.ModelVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteModel(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getModelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiModelModelApi.ModelVO>,
});
onMounted(async () => {
//
apiKeyList.value = await getApiKeySimpleList();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/model/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/model/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['模型配置']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:model:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #keyId="{ row }">
<span>{{
apiKeyList.find((item) => item.id === row.keyId)?.name
}}</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:model:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:model:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { AiModelModelApi } from '#/api/ai/model/model';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createModel, getModel, updateModel } from '#/api/ai/model/model';
import {} from '#/api/bpm/model';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiModelModelApi.ModelVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['模型配置'])
: $t('ui.actionTitle.create', ['模型配置']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as AiModelModelApi.ModelVO;
try {
await (formData.value?.id ? updateModel(data) : createModel(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiModelModelApi.ModelVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getModel(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,111 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '工具名称',
rules: 'required',
},
{
component: 'Textarea',
fieldName: 'description',
label: '工具描述',
componentProps: {
placeholder: '请输入工具描述',
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
defaultValue: CommonStatusEnum.ENABLE,
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '工具名称',
component: 'Input',
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '工具编号',
},
{
field: 'name',
title: '工具名称',
},
{
field: 'description',
title: '工具描述',
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,34 +1,132 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelToolApi } from '#/api/ai/model/tool';
import { Button } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteTool, getToolPage } from '#/api/ai/model/tool';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 */
function handleEdit(row: AiModelToolApi.ToolVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiModelToolApi.ToolVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteTool(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getToolPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiModelToolApi.ToolVO>,
});
</script>
<template>
<Page>
<template #doc>
<DocAlert
title="AI 工具调用function calling"
url="https://doc.iocoder.cn/ai/tool/"
/>
</template>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/tool/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/model/tool/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert
title="AI 工具调用function calling"
url="https://doc.iocoder.cn/ai/tool/"
/>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['工具']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:tool:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:tool:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:tool:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { AiModelToolApi } from '#/api/ai/model/tool';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createTool, getTool, updateTool } from '#/api/ai/model/tool';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiModelToolApi.ToolVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['工具'])
: $t('ui.actionTitle.create', ['工具']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as AiModelToolApi.ToolVO;
try {
await (formData.value?.id ? updateTool(data) : createTool(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiModelToolApi.ToolVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getTool(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -1,28 +1,29 @@
<script lang="ts" setup>
import type { Nullable, Recordable } from '@vben/types';
import { ref, unref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import List from './list/index.vue';
import Mode from './mode/index.vue';
defineOptions({ name: 'Index' });
const listRef = ref<Nullable<{ generateMusic: (...args: any) => void }>>(null);
function generateMusic(args: { formData: Recordable<any> }) {
unref(listRef)?.generateMusic(args.formData);
}
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/music/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/music/index/index.vue
代码pull request 贡献给我们
</Button>
<div class="flex h-full items-stretch">
<!-- 模式 -->
<Mode class="flex-none" @generate-music="generateMusic" />
<!-- 音频列表 -->
<List ref="listRef" class="flex-auto" />
</div>
</Page>
</template>

View File

@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { Nullable } from '@vben/types';
import { inject, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image, Slider } from 'ant-design-vue';
import { formatPast } from '#/utils/formatTime';
defineOptions({ name: 'Index' });
const currentSong = inject('currentSong', {});
const audioRef = ref<Nullable<HTMLElement>>(null);
// https://www.runoob.com/tags/ref-av-dom.html
const audioProps = reactive<any>({
autoplay: true,
paused: false,
currentTime: '00:00',
duration: '00:00',
muted: false,
volume: 50,
});
function toggleStatus(type: string) {
audioProps[type] = !audioProps[type];
if (type === 'paused' && audioRef.value) {
if (audioProps[type]) {
audioRef.value.pause();
} else {
audioRef.value.play();
}
}
}
//
function audioTimeUpdate(args: any) {
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss');
}
</script>
<template>
<div
class="b-solid b-1 b-l-none flex h-[72px] items-center justify-between px-2"
style="background-color: #fffffd; border-color: #dcdfe6"
>
<!-- 歌曲信息 -->
<div class="flex gap-[10px]">
<Image
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
:width="45"
/>
<div>
<div>{{ currentSong.name }}</div>
<div class="text-[12px] text-gray-400">{{ currentSong.singer }}</div>
</div>
</div>
<!-- 音频controls -->
<div class="flex items-center gap-[12px]">
<IconifyIcon
icon="majesticons:back-circle"
:size="20"
class="cursor-pointer text-gray-300"
/>
<IconifyIcon
:icon="
audioProps.paused
? 'mdi:arrow-right-drop-circle'
: 'solar:pause-circle-bold'
"
:size="30"
class="cursor-pointer"
@click="toggleStatus('paused')"
/>
<IconifyIcon
icon="majesticons:next-circle"
:size="20"
class="cursor-pointer text-gray-300"
/>
<div class="flex items-center gap-[16px]">
<span>{{ audioProps.currentTime }}</span>
<Slider
v-model:value="audioProps.duration"
color="#409eff"
class="w-[160px!important]"
/>
<span>{{ audioProps.duration }}</span>
</div>
<!-- 音频 -->
<audio
v-bind="audioProps"
ref="audioRef"
controls
v-show="!audioProps"
@timeupdate="audioTimeUpdate"
>
<!-- <source :src="audioUrl" /> -->
</audio>
</div>
<div class="flex items-center gap-[16px]">
<IconifyIcon
:icon="audioProps.muted ? 'tabler:volume-off' : 'tabler:volume'"
:size="20"
class="cursor-pointer"
@click="toggleStatus('muted')"
/>
<Slider
v-model:value="audioProps.volume"
color="#409eff"
class="w-[160px!important]"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { provide, ref } from 'vue';
import { Col, Empty, Row, TabPane, Tabs } from 'ant-design-vue';
import audioBar from './audioBar/index.vue';
import songCard from './songCard/index.vue';
import songInfo from './songInfo/index.vue';
defineOptions({ name: 'Index' });
const currentType = ref('mine');
// loading
const loading = ref(false);
//
const currentSong = ref({});
const mySongList = ref<Recordable<any>[]>([]);
const squareSongList = ref<Recordable<any>[]>([]);
/*
*@Description: 调接口生成音乐列表
*@MethodAuthor: xiaohong
*@Date: 2024-06-27 17:06:44
*/
function generateMusic(formData: Recordable<any>) {
loading.value = true;
setTimeout(() => {
mySongList.value = Array.from({ length: 20 }, (_, index) => {
return {
id: index,
audioUrl: '',
videoUrl: '',
title: `我走后${index}`,
imageUrl:
'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg',
desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic',
date: '2024年04月30日 14:02:57',
lyric: `<div class="_words_17xen_66"><div>大江东去,浪淘尽,千古风流人物。
</div><div>故垒西边人道是三国周郎赤壁
</div><div>乱石穿空惊涛拍岸卷起千堆雪
</div><div>江山如画一时多少豪杰
</div><div>
</div><div>遥想公瑾当年小乔初嫁了雄姿英发
</div><div>羽扇纶巾谈笑间樯橹灰飞烟灭
</div><div>故国神游多情应笑我早生华发
</div><div>人生如梦一尊还酹江月</div></div>`,
};
});
loading.value = false;
}, 3000);
}
/*
*@Description: 设置当前播放的音乐
*@MethodAuthor: xiaohong
*@Date: 2024-07-19 11:22:33
*/
function setCurrentSong(music: Recordable<any>) {
currentSong.value = music;
}
defineExpose({
generateMusic,
});
provide('currentSong', currentSong);
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-auto overflow-hidden">
<Tabs
v-model:active-key="currentType"
class="flex-auto px-[20px]"
tab-position="bottom"
>
<!-- 我的创作 -->
<TabPane key="mine" tab="我的创作" v-loading="loading">
<Row v-if="mySongList.length > 0" :gutter="12">
<Col v-for="song in mySongList" :key="song.id" :span="24">
<songCard :song-info="song" @play="setCurrentSong(song)" />
</Col>
</Row>
<Empty v-else description="暂无音乐" />
</TabPane>
<!-- 试听广场 -->
<TabPane key="square" tab="试听广场" v-loading="loading">
<Row v-if="squareSongList.length > 0" :gutter="12">
<Col v-for="song in squareSongList" :key="song.id" :span="24">
<songCard :song-info="song" @play="setCurrentSong(song)" />
</Col>
</Row>
<Empty v-else description="暂无音乐" />
</TabPane>
</Tabs>
<!-- songInfo -->
<songInfo class="flex-none" />
</div>
<audioBar class="flex-none" />
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-tabs) {
.ant-tabs__content {
padding: 0 7px;
overflow: auto;
}
}
</style>

View File

@ -0,0 +1,50 @@
<script lang="ts" setup>
import { inject } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image } from 'ant-design-vue';
defineOptions({ name: 'Index' });
defineProps({
songInfo: {
type: Object,
default: () => ({}),
},
});
const emits = defineEmits(['play']);
const currentSong = inject('currentSong', {});
function playSong() {
emits('play');
}
</script>
<template>
<div class="rounded-1 mb-[12px] flex p-[12px]">
<div class="relative" @click="playSong">
<Image :src="songInfo.imageUrl" class="w-80px flex-none" />
<div
class="bg-op-40 absolute left-0 top-0 flex h-full w-full cursor-pointer items-center justify-center bg-black"
>
<IconifyIcon
:icon="
currentSong.id === songInfo.id
? 'solar:pause-circle-bold'
: 'mdi:arrow-right-drop-circle'
"
:size="30"
/>
</div>
</div>
<div class="ml-[8px]">
<div>{{ songInfo.title }}</div>
<div class="mt-[8px] line-clamp-2 text-[12px]">
{{ songInfo.desc }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
<script lang="ts" setup>
import { inject } from 'vue';
import { Button, Card, Image } from 'ant-design-vue';
defineOptions({ name: 'Index' });
const currentSong = inject('currentSong', {});
</script>
<template>
<Card class="line-height-24px mb-[0!important] w-[300px]">
<Image :src="currentSong.imageUrl" style="width: 100%; height: 100%" />
<div class="">{{ currentSong.title }}</div>
<div class="line-clamp-1 text-[12px]">
{{ currentSong.desc }}
</div>
<div class="text-[12px]">
{{ currentSong.date }}
</div>
<Button size="small" shape="round" class="my-[6px]">信息复用</Button>
<div class="text-[12px]" v-html="currentSong.lyric"></div>
</Card>
</template>

View File

@ -0,0 +1,70 @@
<script lang="ts" setup>
import { reactive } from 'vue';
import { Select, Switch, Textarea } from 'ant-design-vue';
import Title from '../title/index.vue';
defineOptions({ name: 'Desc' });
const formData = reactive({
desc: '',
pure: false,
version: '3',
});
defineExpose({
formData,
});
</script>
<template>
<div>
<Title
title="音乐/歌词说明"
desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"
>
<Textarea
v-model:value="formData.desc"
:autosize="{ minRows: 6, maxRows: 6 }"
:maxlength="1200"
:show-count="true"
placeholder="一首关于糟糕分手的欢快歌曲"
/>
</Title>
<Title title="纯音乐" class="mt-[20px]" desc="创建一首没有歌词的歌曲">
<template #extra>
<Switch v-model:checked="formData.pure" size="small" />
</template>
</Title>
<Title
title="版本"
desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"
>
<Select
v-model:value="formData.version"
class="w-full"
placeholder="请选择"
>
<Select.Option
v-for="item in [
{
value: '3',
label: 'V3',
},
{
value: '2',
label: 'V2',
},
]"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Title>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More