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

Merge pull request !145 from gjd/dev_xx
This commit is contained in:
xingyu
2025-06-16 05:44:10 +00:00
committed by Gitee
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 24小时60 * 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-dd、yyyy-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;
}
}
// markmap的tool样式覆盖
: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