This commit is contained in:
vastxie
2025-03-04 17:36:53 +08:00
parent cf7dc1d1e7
commit 1e9b9f7ba4
649 changed files with 23183 additions and 1925 deletions

View File

@@ -1,58 +0,0 @@
# 99AI 项目开发指引
## 项目结构
```plaintext
src/
├── admin/ # 管理端
├── chat/ # 用户端(对话页)
├── service/ # 后端服务
└── build.sh # 一键打包脚本
```
---
## 模块说明
### 1. 用户端chat
- **位置:** `src/chat`
- **功能:**
- 使用 Vue.js 构建。
- 支持 AI 对话、多模态分析、文件上传与解析等用户功能。
---
### 2. 管理端admin
- **位置:** `src/admin`
- **功能:**
- 基于 [Fantastic Admin Basic](https://github.com/fantastic-admin/basic) 开源框架构建。
- 超级管理员和普通管理员的后台管理页面。
- 支持积分系统管理、模型配置、用户管理等功能。
---
### 3. 后端服务service
- **位置:** `src/service`
- **功能:**
- 提供 API 接口,负责模型调用、业务逻辑处理与数据库交互。
- 支持多模态模型、文件分析、用户积分系统等功能。
- 使用 NestJS 构建,默认运行在 `http://localhost:9520`
---
## 一键打包脚本
项目提供了 `build.sh` 脚本,用于快速打包整个项目:
```bash
bash build.sh
```
执行后,所有模块将自动构建,构建后的文件存放在项目根目录的文件夹中。
---
如有其他问题,请查看项目根目录的文档或通过 [issue](https://github.com/vastxie/99AI/issues) 提交反馈。

View File

@@ -1,6 +1,6 @@
{
"type": "module",
"version": "4.0.0",
"version": "4.1.0",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -1,87 +1,148 @@
<script setup lang="ts">
import type { SubMenuItemProps } from './types'
import { rootMenuInjectionKey } from './types'
import type { SubMenuItemProps } from './types';
import { rootMenuInjectionKey } from './types';
const props = withDefaults(
defineProps<SubMenuItemProps>(),
{
level: 0,
subMenu: false,
expand: false,
},
)
const props = withDefaults(defineProps<SubMenuItemProps>(), {
level: 0,
subMenu: false,
expand: false,
});
const rootMenu = inject(rootMenuInjectionKey)!
const rootMenu = inject(rootMenuInjectionKey)!;
const itemRef = ref<HTMLElement>()
const itemRef = ref<HTMLElement>();
const isActived = computed(() => {
return props.subMenu
? rootMenu.subMenus[props.uniqueKey.at(-1)!].active
: rootMenu.activeIndex === props.uniqueKey.at(-1)!
})
: rootMenu.activeIndex === props.uniqueKey.at(-1)!;
});
const isItemActive = computed(() => {
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
})
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup);
});
// 缩进样式
const indentStyle = computed(() => {
return !rootMenu.isMenuPopup
? `padding-left: ${20 * (props.level ?? 0)}px`
: ''
})
: '';
});
defineExpose({
ref: itemRef,
})
});
</script>
<template>
<div
ref="itemRef" class="menu-item relative transition-all" :class="{
ref="itemRef"
class="menu-item relative transition-all"
:class="{
active: isItemActive,
}"
>
<router-link v-slot="{ href, navigate }" custom :to="uniqueKey.at(-1) ?? ''">
<HTooltip :enable="rootMenu.isMenuPopup && level === 0 && !subMenu" :text="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" placement="right" class="h-full w-full">
<router-link
v-slot="{ href, navigate }"
custom
:to="uniqueKey.at(-1) ?? ''"
>
<HTooltip
:enable="rootMenu.isMenuPopup && level === 0 && !subMenu"
:text="
(typeof item.meta?.title === 'function'
? item.meta?.title()
: item.meta?.title) ?? ''
"
placement="right"
class="h-full w-full"
>
<component
:is="subMenu ? 'div' : 'a'" v-bind="{
:is="subMenu ? 'div' : 'a'"
v-bind="{
...(!subMenu && {
href: item.meta?.link ? item.meta.link : href,
target: item.meta?.link ? '_blank' : '_self',
class: 'no-underline',
}),
}" class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-5 py-4 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])" :class="{
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
}"
class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-5 py-4 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])"
:class="{
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!':
isItemActive,
'px-3!': rootMenu.isMenuPopup && level === 0,
}" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" v-on="{
}"
:title="
typeof item.meta?.title === 'function'
? item.meta?.title()
: item.meta?.title
"
v-on="{
...(!subMenu && {
click: navigate,
}),
}"
>
<div
class="inline-flex flex-1 items-center justify-center gap-[12px]" :class="{
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
'gap-1!': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
'w-full': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName && rootMenu.props.mode === 'vertical',
}" :style="indentStyle"
class="inline-flex flex-1 items-center justify-center gap-[12px]"
:class="{
'flex-col':
rootMenu.isMenuPopup &&
level === 0 &&
rootMenu.props.mode === 'vertical',
'gap-1!':
rootMenu.isMenuPopup &&
level === 0 &&
rootMenu.props.showCollapseName,
'w-full':
rootMenu.isMenuPopup &&
level === 0 &&
rootMenu.props.showCollapseName &&
rootMenu.props.mode === 'vertical',
}"
:style="indentStyle"
>
<SvgIcon v-if="props.item.meta?.icon" :name="props.item.meta.icon" :size="20" class="menu-item-container-icon transition-transform group-hover-scale-120" async />
<SvgIcon
v-if="props.item.meta?.icon"
:name="props.item.meta.icon"
:size="20"
class="menu-item-container-icon transition-transform group-hover-scale-120"
async
/>
<span
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)" class="w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width"
v-if="
!(
rootMenu.isMenuPopup &&
level === 0 &&
!rootMenu.props.showCollapseName
)
"
class="w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width"
:class="{
'opacity-0 w-0 h-0': rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName,
'w-full text-center': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
'opacity-0 w-0 h-0':
rootMenu.isMenuPopup &&
level === 0 &&
!rootMenu.props.showCollapseName,
'w-full text-center':
rootMenu.isMenuPopup &&
level === 0 &&
rootMenu.props.showCollapseName,
}"
>
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
{{
typeof item.meta?.title === 'function'
? item.meta?.title()
: item.meta?.title
}}
</span>
</div>
<i
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)" class="relative ml-1 w-[10px] after:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px]) before:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px])" :class="[
expand ? 'before:(-rotate-45 -translate-x-[2px]) after:(rotate-45 translate-x-[2px])' : 'before:(rotate-45 -translate-x-[2px]) after:(-rotate-45 translate-x-[2px])',
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)"
class="relative ml-1 w-[10px] after:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px]) before:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px])"
:class="[
expand
? 'before:(-rotate-45 -translate-x-[2px]) after:(rotate-45 translate-x-[2px])'
: 'before:(rotate-45 -translate-x-[2px]) after:(-rotate-45 translate-x-[2px])',
rootMenu.isMenuPopup && level === 0 && 'opacity-0',
rootMenu.isMenuPopup && level !== 0 && '-rotate-90 -top-[1.5px]',
]"

View File

@@ -46,6 +46,7 @@ declare global {
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
@@ -68,13 +69,16 @@ declare global {
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useGlobalProperties: typeof import('../utils/composables/useGlobalProperties')['default']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useMainPage: typeof import('../utils/composables/useMainPage')['default']
const useMenu: typeof import('../utils/composables/useMenu')['default']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTabbar: typeof import('../utils/composables/useTabbar')['default']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useViewTransition: typeof import('../utils/composables/useViewTransition')['default']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']

View File

@@ -17,10 +17,6 @@ const formRef = ref<FormInstance>();
const total = ref(0);
const visible = ref(false);
const loading = ref(false);
const isSystemPluginMap = {
1: '内置插件',
0: '普通插件',
};
const parameterOptions: string[] = [
'dall-e-3',
@@ -42,7 +38,6 @@ const formInline = reactive({
description: '',
demoData: '',
isEnabled: 1,
isSystemPlugin: 0,
parameters: '',
sortOrder: 100,
page: 1,
@@ -65,7 +60,6 @@ const formPackage = reactive({
description: '',
demoData: '',
isEnabled: 1,
isSystemPlugin: 0,
parameters: '',
sortOrder: 100,
});
@@ -89,14 +83,7 @@ const rules = reactive<FormRules>({
trigger: 'change',
},
],
isSystemPlugin: [
{
required: true,
type: 'number',
message: '请选择是否为系统插件',
trigger: 'change',
},
],
parameters: [{ required: true, message: '请填写调用参数', trigger: 'blur' }],
sortOrder: [
{
@@ -149,7 +136,6 @@ function handleUpdatePackage(row: any) {
description,
demoData,
isEnabled,
isSystemPlugin,
parameters,
sortOrder,
} = row;
@@ -160,7 +146,6 @@ function handleUpdatePackage(row: any) {
description,
demoData,
isEnabled,
isSystemPlugin,
parameters,
sortOrder,
});
@@ -286,19 +271,15 @@ onMounted(() => {
</div>
<div>插件系统包含内置插件和普通插件两种</div>
<div>
内置插件已支持Suno音乐参数suno-musicMidjourney绘图参数midjourneyStable
插件已支持Suno音乐参数suno-musicMidjourney绘图参数midjourneyStable
Diffusion绘图参数stable-diffusion
</div>
<div>
Dalle绘画参数dall-e-3LumaVideo参数luma-videoCogVideoX参数cog-videoFlux绘画参数flux-draw均需通过创意模型配置对应模型
</div>
<div>
普通插件需外部插件系统支持具体参数请查看<a
href="https://github.com/vastxie/99AIPlugin"
target="_blank"
>插件系统</a
>
联网插件(参数net-search)思维导图(参数mind-map)联网插件需在
插件应用基础配置 中单独配置联网地址
</div>
<div>
若内置插件参数不在支持列表内将以插件参数作为模型调用对应模型
@@ -343,13 +324,6 @@ onMounted(() => {
</div>
</template>
</el-table-column>
<el-table-column prop="isSystemPlugin" label="分类" width="100">
<template #default="scope">
<el-tag :type="scope.row.isSystemPlugin === 1 ? 'success' : 'info'">
{{ scope.row.isSystemPlugin === 1 ? '内置插件' : '普通插件' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isEnabled" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.isEnabled === 1 ? 'success' : 'danger'">
@@ -427,13 +401,6 @@ onMounted(() => {
:rows="4"
/>
</el-form-item>
<el-form-item label="系统插件" prop="status">
<el-switch
v-model="formPackage.isSystemPlugin"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="插件参数" prop="parameters">
<el-select
v-model="formPackage.parameters"

View File

@@ -23,6 +23,21 @@ const rules = ref<FormRules>({
pluginKey: [{ required: true, trigger: 'blur', message: '请填写插件key' }],
});
const netWorkOptions = [
{
value: 'https://open.bigmodel.cn/api/paas/v4/tools',
label: '【智谱 web-search-pro】',
},
{
value: 'https://api.bochaai.com/v1/web-search',
label: '【博查 web-search】',
},
{
value: 'https://api.tavily.com/search',
label: '【Tavily 1000 次/月(免费)】',
},
];
const formRef = ref<FormInstance>();
async function queryAllconfig() {
@@ -69,15 +84,19 @@ onMounted(() => {
<template #content>
<div class="text-sm/6">
<div>
插件基础配置包括插件地址插件 Key隐藏插件插件优先显示等
插件基础配置包括联网插件地址联网插件
Key隐藏插件插件优先显示等
</div>
<div>
插件项目<a
href="https://github.com/vastxie/99AIPlugin"
target="_blank"
>开源地址</a
>
可自行部署欢迎共同维护
联网插件已支持多种方式
<a href="https://bigmodel.cn" target="_blank">智谱 web-search-pro</a
>
<a href="https://open.bochaai.com" target="_blank"
>博查 web-search</a
>
<a href="https://app.tavily.com/home" target="_blank">Tavily</a>
需自行登录以上网站获取对应的 Key多个Key用英文逗号隔开
</div>
</div>
</template>
@@ -96,22 +115,33 @@ onMounted(() => {
>
<el-row>
<el-col :xs="24" :md="20" :lg="15" :xl="12">
<el-form-item label="插件地址" prop="pluginUrl">
<el-input
<el-form-item label="联网插件地址" prop="pluginUrl">
<el-select
v-model="formInline.pluginUrl"
placeholder="插件地址"
placeholder="请选择或输入联网搜索使用的地址"
clearable
/>
filterable
allow-create
>
<el-option
v-for="option in netWorkOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :xs="24" :md="20" :lg="15" :xl="12">
<el-form-item label="插件 Key" prop="pluginKey">
<el-form-item label="联网插件 Key" prop="pluginKey">
<el-input
v-model="formInline.pluginKey"
placeholder="插件 Key"
clearable
password
show-password
/>
</el-form-item>
</el-col>

View File

@@ -33,7 +33,7 @@ const bulkVisible = ref(false);
const formInline = reactive({
keyType: '',
model: '',
status: null,
status: undefined,
page: 1,
size: 10,
});

View File

@@ -35,7 +35,7 @@ const selects = ref([]);
const selectCramiList = ref<any[]>([]);
const form = reactive({
packageId: null,
packageId: undefined,
count: 1,
drawMjCount: 0,
model3Count: 0,

View File

@@ -40,7 +40,7 @@ interface Package {
coverImg?: string | null;
price?: number | null;
order?: number | null;
status?: number | null;
status: number;
weight?: number | null;
days?: number | null;
model3Count: number | null;
@@ -57,7 +57,7 @@ const formPackage: Package = reactive({
coverImg: null,
price: null,
order: null,
status: null,
status: 0,
weight: null,
days: null,
model3Count: null,

View File

@@ -2,26 +2,47 @@
set -e
cd admin/
# 1. 构建所有项目
echo "开始构建项目..."
pnpm install
# 构建管理端
cd admin/
pnpm build
cd ..
# 构建前端
cd chat/
pnpm build
cd ..
# 构建服务端
cd service/
pnpm build
cd ..
rm -rf ../dist/* ../public/admin/* ../public/chat/*
mkdir -p ../dist ../public/admin ../public/chat
# 2. 清理并创建目标目录
echo "准备目标目录..."
rm -rf ../AIWebQuickDeploy/dist/* ../AIWebQuickDeploy/public/admin/* ../AIWebQuickDeploy/public/chat/*
mkdir -p ../AIWebQuickDeploy/dist ../AIWebQuickDeploy/public/admin ../AIWebQuickDeploy/public/chat
cp service/package.json ../package.json
# 3. 复制服务端配置文件
echo "复制服务端配置..."
cp service/pm2.conf.json ../AIWebQuickDeploy/pm2.conf.json
cp service/package.json ../AIWebQuickDeploy/package.json
cp -r service/dist/* ../dist
cp -r admin/dist/* ../public/admin
cp -r chat/dist/* ../public/chat
# 4. 复制配置文件
echo "复制配置文件..."
cp service/.env.example ../AIWebQuickDeploy/.env.example
cp service/.env.docker ../AIWebQuickDeploy/.env.docker
cp service/.dockerignore ../AIWebQuickDeploy/.dockerignore
cp service/Dockerfile ../AIWebQuickDeploy/Dockerfile
cp service/docker-compose.yml ../AIWebQuickDeploy/docker-compose.yml
echo "打包完成!"
# 5. 复制构建产物
echo "复制构建文件..."
cp -r service/dist/* ../AIWebQuickDeploy/dist
cp -r admin/dist/* ../AIWebQuickDeploy/public/admin
cp -r chat/dist/* ../AIWebQuickDeploy/public/chat
echo "打包完成"

View File

@@ -1,8 +1,8 @@
{
"name": "99ai-chat",
"version": "4.0.0",
"version": "4.1.0",
"private": true,
"description": "ChatGPT Cooper",
"description": "99AI chat",
"author": "vastxie",
"keywords": [
"aiweb",
@@ -26,6 +26,8 @@
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/state": "^6.5.2",
"@headlessui/vue": "^1.7.22",
"@heroicons/vue": "^2.1.1",
"@icon-park/vue-next": "^1.4.2",
@@ -35,7 +37,9 @@
"@vueuse/core": "^9.13.0",
"@vueuse/integrations": "^10.2.0",
"@vueuse/motion": "^2.0.0",
"browserslist": "^4.24.4",
"clientjs": "^0.2.1",
"codemirror": "^6.0.1",
"file-saver": "^2.0.5",
"highlight.js": "^11.7.0",
"html-to-image": "^1.11.11",
@@ -47,13 +51,14 @@
"markmap-view": "0.14.4",
"md-editor-v3": "^4.17.4",
"naive-ui": "^2.34.3",
"npx": "^10.2.2",
"pinia": "^2.0.33",
"pinyin-match": "^1.2.6",
"qrcode": "^1.5.3",
"tailwind-scrollbar": "^3.1.0",
"v-viewer": "3.0.11",
"vue": "^3.4.30",
"vue-i18n": "^9.2.2",
"vue-i18n": "^11.1.1",
"vue-router": "^4.1.6",
"vue3-pdfjs": "^0.1.6",
"xml2js": "^0.6.2"
@@ -74,6 +79,7 @@
"tailwindcss": "^3.4.1",
"terser": "^5.31.1",
"typescript": "~4.9.5",
"update-browserslist-db": "^1.1.2",
"vite": "^4.2.0",
"vue-tsc": "^1.2.0"
},

View File

@@ -8,11 +8,14 @@ import {
Close,
Copy,
Delete,
Down,
Edit,
LoadingOne,
PauseOne,
Refresh,
Rotation,
Send,
Up,
VoiceMessage,
} from '@icon-park/vue-next';
import mdKatex from '@traptitech/markdown-it-katex';
@@ -45,49 +48,35 @@ interface Emit {
(ev: 'copy'): void;
}
interface TtsResponse {
ttsUrl: string;
}
const authStore = useAuthStore();
const { isMobile } = useBasicLayout();
const onConversation = inject<any>('onConversation');
const handleRegenerate = inject<any>('handleRegenerate');
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const modalVisible = ref(false);
const editableText = ref(props.text);
const isHideTts = computed(
() => Number(authStore.globalConfig?.isHideTts) === 1
);
const emit = defineEmits<Emit>();
const { isMobile } = useBasicLayout();
const showThinking = ref(true);
const textRef = ref<HTMLElement>();
const localTtsUrl = ref(props.ttsUrl);
const htmlContent = ref<string>('');
const isHtml = ref(false);
const playbackState = ref('paused');
let currentAudio: HTMLAudioElement | null = null;
const editableContent = ref(props.text); // 新增一个引用来存储编辑后的内容
const isEditable = ref(false); // 定义一个状态变量控制是否进入编辑模式
const editableContent = ref(props.text);
const isEditable = ref(false);
const textarea = ref<HTMLTextAreaElement | null>(null);
function adjustTextareaHeight() {
if (textarea.value) {
textarea.value.style.height = 'auto';
textarea.value.style.height = textarea.value.scrollHeight + 'px';
}
}
let currentAudio: HTMLAudioElement | null = null;
function cancelEdit() {
editableContent.value = props.text; // 重置已编辑的内容
isEditable.value = false; // 退出编辑模式
}
// 创建一个响应式引用来存储音频URL
const isHideTts = computed(
() => Number(authStore.globalConfig?.isHideTts) === 1
);
const buttonGroupClass = computed(() => {
return playbackState.value !== 'paused' || isEditable.value
@@ -95,26 +84,26 @@ const buttonGroupClass = computed(() => {
: 'opacity-0 group-hover:opacity-100';
});
// 这个函数用于获取音频URL并播放音频
async function handlePlay() {
if (playbackState.value === 'loading' || playbackState.value === 'playing') {
const handlePlay = async () => {
if (playbackState.value === 'loading' || playbackState.value === 'playing')
return;
}
// 使用localTtsUrl的值进行判断
if (localTtsUrl.value) {
playAudio(localTtsUrl.value);
return;
}
// 这里可以加入你的文本提示,或者根据组件内部的状态来确定
playbackState.value = 'loading';
try {
const res = await fetchTtsAPIProces({
if (!props.chatId) return;
const res = (await fetchTtsAPIProces({
chatId: props.chatId,
prompt: props.text,
});
const ttsUrl = res.ttsUrl; // 假设响应中包含ttsUrl
})) as TtsResponse;
const ttsUrl = res.ttsUrl;
if (ttsUrl) {
localTtsUrl.value = ttsUrl; // 更新本地ttsUrl
localTtsUrl.value = ttsUrl;
playAudio(ttsUrl);
} else {
throw new Error('TTS URL is undefined');
@@ -122,57 +111,54 @@ async function handlePlay() {
} catch (error) {
playbackState.value = 'paused';
}
}
};
function playAudio(audioSrc: string | undefined) {
// 如果之前已经有音频在播放,先停止它
if (currentAudio) {
currentAudio.pause();
}
// 创建新的音频对象并播放
currentAudio = new Audio(audioSrc);
currentAudio
.play()
.then(() => {
playbackState.value = 'playing'; // 更新状态为播放中
playbackState.value = 'playing';
})
.catch((error) => {
playbackState.value = 'paused'; // 出错时更新状态为暂停
playbackState.value = 'paused';
});
// 当音频播放结束时更新状态
currentAudio.onended = () => {
playbackState.value = 'paused'; // 音频结束时更新状态为暂停
currentAudio = null; // 清除引用
playbackState.value = 'paused';
currentAudio = null;
};
}
function pauseAudio() {
if (currentAudio) {
currentAudio.pause(); // 暂停音频
playbackState.value = 'paused'; // 更新状态为暂停
currentAudio.pause();
playbackState.value = 'paused';
}
}
function playOrPause() {
if (playbackState.value === 'playing') {
pauseAudio(); // 如果当前是播放状态,则暂停
pauseAudio();
} else {
handlePlay(); // 否则,尝试播放音频
handlePlay();
}
}
const mdi = new MarkdownIt({
linkify: true,
html: true,
highlight(code, language) {
const validLang = !!(language && hljs.getLanguage(language));
if (validLang) {
const lang = language ?? '';
// 检测到HTML代码块时直接返回原始内容
console.log('language', language);
if (language === 'html') {
htmlContent.value = code; // 存储HTML内容
isHtml.value = true; // 标记为HTML内容
htmlContent.value = code;
isHtml.value = true;
console.log('htmlContent', htmlContent.value);
}
return highlightBlock(
@@ -185,21 +171,169 @@ const mdi = new MarkdownIt({
},
});
// 添加自定义渲染规则来处理图片
mdi.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const src = token.attrGet('src');
const title = token.attrGet('title');
const alt = token.content;
// 使用普通的<img>标签,你可以根据需要添加或调整属性
return `<img src="${src}" alt="${alt}" title="${
title || alt
}" class="rounded-md" style=" max-width:100% ;max-height: 30vh; "/>`;
if (isMobile.value) {
return `<img src="${src}" alt="${alt}" title="${
title || alt
}" class="rounded-md"
style=" max-width:100% "
onclick="(function() {
const modal = document.createElement('div');
modal.style.position = 'fixed';
modal.style.top = 0;
modal.style.left = 0;
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.background = 'rgba(0, 0, 0, 1)';
modal.style.display = 'flex';
modal.style.justifyContent = 'center';
modal.style.alignItems = 'center';
modal.style.zIndex = 1000;
modal.style.overflow = 'hidden';
modal.onclick = (event) => {
document.body.removeChild(modal);
};
const img = document.createElement('img');
img.src = '${src}';
img.style.maxWidth = '100%';
img.style.maxHeight = '95%';
img.style.transform = 'scale(1) translate(0px, 0px)';
img.style.transition = 'transform 0.2s';
let scale = 1;
let posX = 0, posY = 0;
let isDragging = false;
let startX, startY;
let initialDistance = null;
modal.addEventListener('touchmove', (event) => {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.sqrt(
(touch2.clientX - touch1.clientX) ** 2 +
(touch2.clientY - touch1.clientY) ** 2
);
if (initialDistance === null) {
initialDistance = currentDistance;
} else {
const scaleChange = currentDistance / initialDistance;
scale = Math.max(0.5, Math.min(scale * scaleChange, 3));
img.style.transform = \`scale(\${scale}) translate(\${posX}px, \${posY}px)\`;
initialDistance = currentDistance;
}
}
});
modal.addEventListener('touchend', () => {
initialDistance = null;
});
modal.appendChild(img);
document.body.appendChild(modal);
})()"
/>`;
} else {
return `<img src="${src}" alt="${alt}" title="${
title || alt
}" class="rounded-md"
style="max-width:100% ;max-height: 30vh;"
onclick="(function() {
const modal = document.createElement('div');
modal.style.position = 'fixed';
modal.style.top = 0;
modal.style.left = 0;
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.background = 'rgba(0, 0, 0, 0.5)';
modal.style.display = 'flex';
modal.style.justifyContent = 'center';
modal.style.alignItems = 'center';
modal.style.zIndex = 1000;
modal.style.overflow = 'hidden';
modal.onclick = (event) => {
if (event.target === modal) {
document.body.removeChild(modal);
}
};
const img = document.createElement('img');
img.src = '${src}';
img.style.maxWidth = '95%';
img.style.maxHeight = '95%';
img.style.borderRadius = '8px';
img.style.transform = 'scale(1) translate(0px, 0px)';
img.style.transition = 'transform 0.2s';
let scale = 1;
let posX = 0, posY = 0;
let isDragging = false;
let startX, startY;
modal.addEventListener('wheel', (event) => {
event.preventDefault();
const zoomFactor = 0.1;
if (event.deltaY < 0) {
scale = Math.min(scale + zoomFactor, 3);
} else {
scale = Math.max(scale - zoomFactor, 0.5);
}
img.style.transform = \`scale(\${scale}) translate(\${posX}px, \${posY}px)\`;
});
let initialDistance = null;
modal.addEventListener('touchmove', (event) => {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.sqrt(
(touch2.clientX - touch1.clientX) ** 2 +
(touch2.clientY - touch1.clientY) ** 2
);
if (initialDistance === null) {
initialDistance = currentDistance;
} else {
const scaleChange = currentDistance / initialDistance;
scale = Math.max(0.5, Math.min(scale * scaleChange, 3));
img.style.transform = \`scale(\${scale}) translate(\${posX}px, \${posY}px)\`;
initialDistance = currentDistance;
}
}
});
modal.addEventListener('touchend', () => {
initialDistance = null;
});
const close = document.createElement('span');
close.innerText = '×';
close.style.position = 'absolute';
close.style.top = '24px';
close.style.right = '24px';
close.style.color = 'white';
close.style.fontSize = '1.5rem';
close.style.cursor = 'pointer';
close.onclick = () => document.body.removeChild(modal);
modal.appendChild(img);
modal.appendChild(close);
document.body.appendChild(modal);
})()"
/>`;
}
};
const fileInfo = computed(() => props.fileInfo);
// 将 fileInfo 转换为数组
const fileInfoArray = computed(() =>
fileInfo?.value?.split(',').map((file) => file.trim())
);
@@ -221,32 +355,72 @@ mdi.use(mdKatex, {
errorColor: ' #cc0000',
});
const text = computed(() => {
const value = props.text ?? '';
const mainContent = computed(() => {
let value = props.text || '';
// 使用正则表达式替换 \( 后面的空格和 \) 前面的空格,以及 \[ 和 \] 的情况
const modifiedValue = value
if (value.startsWith('<think>')) {
const endThinkTagIndex = value.indexOf('</think>');
if (endThinkTagIndex !== -1) {
value =
value.slice(0, value.indexOf('<think>')) +
value.slice(endThinkTagIndex + 8);
} else {
value = '';
}
}
let modifiedValue = value
.replace(/\\\(\s*/g, '$')
.replace(/\s*\\\)/g, '$')
.replace(/\\\[\s*/g, '$$')
.replace(/\s*\\\]/g, '$$')
.replace(
/\[\[(\d+)\]\((https?:\/\/[^\)]+)\)\]/g,
'<button class="bg-gray-500 text-white rounded-full w-4 h-4 mx-1 flex justify-center items-center text-sm hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-500 inline-flex" onclick="window.open(\'$2\', \'_blank\')">$1</button>'
);
if (!props.asRawText) {
return mdi.render(modifiedValue);
}
return modifiedValue;
});
const thinkingContent = computed<string>(() => {
let content = '';
if (props.text?.includes('<think>') && !props.text.includes('</think>')) {
content = props.text.replace(/<think>/g, '').trim();
} else {
const match = props.text?.match(/<think>([\s\S]*?)<\/think>/);
if (match) {
content = match[1];
}
}
const modifiedContent = content
.replace(/\\\(\s*/g, '$')
.replace(/\s*\\\)/g, '$')
.replace(/\\\[\s*/g, '$$')
.replace(/\s*\\\]/g, '$$');
if (!props.asRawText) {
const renderedValue = mdi.render(modifiedValue);
return renderedValue;
return mdi.render(modifiedContent);
}
return modifiedValue;
return modifiedContent;
});
function highlightBlock(str: string, lang?: string) {
return `<pre style=" max-width:100% " class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${t(
return `<pre
style=" max-width:100%; background-color: #212121; "
class="code-block-wrapper"
><div class="code-block-header "><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${t(
'chat.copyCode'
)}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`;
}
async function handleEdit() {
// await chatStore.queryActiveChatLogList();
editableText.value = props.text;
modalVisible.value = true;
}
@@ -257,18 +431,18 @@ function closeModal() {
async function handleEditMessage() {
if (isEditable.value) {
const tempEditableContent = editableContent.value; // 存储编辑后的内容到临时变量
const tempEditableContent = editableContent.value;
await onConversation({
msg: tempEditableContent, // 使用临时变量中的编辑后的内容
msg: tempEditableContent,
chatId: props.chatId,
});
isEditable.value = false; // 提交后退出编辑模式
isEditable.value = false;
} else {
editableContent.value = props.text;
isEditable.value = true; // 进入编辑模式
await nextTick(); // 确保 DOM 更新完成
adjustTextareaHeight(); // 调整高度
isEditable.value = true;
await nextTick();
adjustTextareaHeight();
}
}
@@ -286,6 +460,18 @@ function handleDelete() {
emit('delete');
}
const cancelEdit = () => {
isEditable.value = false;
editableContent.value = props.text;
};
const adjustTextareaHeight = () => {
if (textarea.value) {
textarea.value.style.height = 'auto';
textarea.value.style.height = `${textarea.value.scrollHeight}px`;
}
};
defineExpose({ textRef });
onMounted(() => {
@@ -300,6 +486,27 @@ onMounted(() => {
<template>
<div class="flex flex-col group w-full">
<div class="text-wrap rounded-lg min-w-12 flex w-full flex-col">
<div
v-if="thinkingContent && !inversion"
@click="showThinking = !showThinking"
class="text-gray-600 flex items-center mb-1 text-base hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 cursor-pointer"
>
<span>{{ mainContent || !loading ? '已深度思考' : '深度思考中' }}</span>
<LoadingOne
v-if="!mainContent && loading"
class="rotate-icon flex mx-1"
/>
<Down v-if="!showThinking" size="20" class="mr-1 flex" />
<Up v-else size="20" class="mr-1 flex" />
</div>
<div
v-else-if="loading && thinkingContent && !inversion"
class="text-gray-600 flex items-center mb-1 text-base hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 cursor-pointer"
>
<span>深度思考</span>
<LoadingOne class="rotate-icon flex mx-1" />
</div>
<div ref="textRef" class="flex w-full">
<div
v-if="!inversion"
@@ -307,13 +514,26 @@ onMounted(() => {
style="max-width: 100%"
>
<div class="w-full">
<span v-if="loading && !text" class="loading-anchor"></span>
<span
v-if="loading && !text"
class="inline-block w-3.5 h-3.5 ml-0.5 align-middle rounded-full animate-breathe dark:bg-gray-100 bg-gray-950"
></span>
<div
v-if="thinkingContent && showThinking"
:class="[
'markdown-body text-gray-600 dark:text-gray-400 pl-5 mt-2 mb-4 border-l-2 border-gray-300 dark:border-gray-600 overflow-hidden transition-opacity duration-500 ease-in-out',
{
'markdown-body-generate': loading && !mainContent,
},
]"
v-html="thinkingContent"
></div>
<div
:class="[
'markdown-body text-gray-950 dark:text-gray-100',
{ 'markdown-body-generate': loading || !text },
{ 'markdown-body-generate': loading || !mainContent },
]"
v-html="text"
v-html="mainContent"
></div>
</div>
</div>
@@ -326,7 +546,7 @@ onMounted(() => {
>
<div
v-if="isEditable"
class="p-3 rounded-lg w-full bg-opacity dark:bg-gray-750 break-words"
class="p-3 rounded-2xl w-full bg-opacity dark:bg-gray-750 break-words"
style="max-width: 100%"
>
<textarea
@@ -339,7 +559,7 @@ onMounted(() => {
</div>
<div
v-else
class="p-3 rounded-lg text-base bg-opacity dark:bg-gray-750 break-words whitespace-pre-wrap text-gray-950 dark:text-gray-100"
class="p-3 rounded-2xl text-base bg-opacity dark:bg-gray-750 break-words whitespace-pre-wrap text-gray-950 dark:text-gray-100"
v-text="text"
style="max-width: 100%"
/>
@@ -388,7 +608,7 @@ onMounted(() => {
<div class="flex justify-start">
<button
@click="handleEdit"
class="px-4 py-1 shadow-sm ring-1 ring-inset bg-white ring-gray-300 hover:bg-gray-50 text-gray-900 rounded-md mr-4 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:ring-gray-700 dark:hover:ring-gray-600"
class="px-4 py-1 shadow-sm ring-1 ring-inset bg-white ring-gray-300 hover:bg-gray-50 text-gray-900 rounded-lg mr-4 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:ring-gray-700 dark:hover:ring-gray-600"
>
预览代码
</button>
@@ -403,12 +623,12 @@ onMounted(() => {
v-for="(item, index) in promptReference
? promptReference
.match(/{(.*?)}/g)
?.map((str) => str.slice(1, -1))
?.map((str: string | any[]) => str.slice(1, -1))
.slice(0, 3)
: []"
:key="index"
@click="handleMessage(item)"
class="flex flex-row items-center my-3 px-2 py-2 shadow-sm bg-opacity hover:bg-gray-50 text-left text-gray-900 rounded-md overflow-hidden dark:bg-gray-750 dark:text-gray-400"
@click="handleMessage(item as string)"
class="flex flex-row items-center my-3 px-3 py-2 shadow-sm bg-opacity hover:bg-gray-50 text-left text-gray-900 rounded-full overflow-hidden dark:bg-gray-750 dark:text-gray-400"
>
{{ item }}
<ArrowRight class="ml-1" />
@@ -571,22 +791,7 @@ onMounted(() => {
}
}
.loading-anchor::after {
content: '';
/* 不使用文本内容 */
display: inline-block;
width: 0.875em;
/* 控制原点的大小 */
height: 0.875em;
/* 保持原点为圆形 */
margin-left: 2px;
background-color: #000;
/* 原点颜色 */
border-radius: 50%;
/* 使其成为圆形 */
.animate-breathe {
animation: breathe 2s infinite ease-in-out;
/* 应用呼吸效果动画 */
vertical-align: middle;
/* 根据需要调整垂直对齐 */
}
</style>

View File

@@ -10,40 +10,12 @@
"AI"
],
"dependencies": {
"eslint-config-standard-with-typescript": "^43.0.1",
"eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.8.1",
"eslint-plugin-antfu": "^0.35.3",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-html": "^7.1.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-markdown": "^3.0.1",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-promise": "^6.4.0",
"eslint-plugin-unicorn": "^45.0.2",
"eslint-plugin-yml": "^1.14.0",
"eslint-rule-composer": "^0.3.0",
"eslint-scope": "^7.2.2",
"eslint-utils": "^3.0.0",
"eslint-visitor-keys": "^3.4.3",
"jsonc-eslint-parser": "^2.4.0",
"prettier-linter-helpers": "^1.0.0",
"vue-eslint-parser": "^9.4.3",
"yaml-eslint-parser": "^1.2.3"
"prettier-linter-helpers": "^1.0.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"eslint-plugin-vue": "^9.26.0",
"prettier": "^2.8.8",
"typescript": "^5.5.3"
},
@@ -56,4 +28,4 @@
"url": "https://github.com/AIWeb-Team/AIWeb/issues"
},
"homepage": "https://github.com/AIWeb-Team/AIWeb/blob/main/README.md"
}
}

20728
src/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

20
src/service/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
node_modules
data
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### VS Code ###
.vscode/
### Macos ###
.DS_Store
.env
.git
.gitignore
docker
docs
README.md

27
src/service/.env.docker Normal file
View File

@@ -0,0 +1,27 @@
# server base
PORT=9520
DB_HOST=mysql
DB_PORT=3306
DB_USER=root
DB_PASS=123456
DB_DATABASE=chatgpt
DB_SYNC=true
# Redis
REDIS_PORT=6379
REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_USER=
REDIS_DB=0
# 是否测试环境
ISDEV=FALSE
# 自定义微信URL
weChatOpenUrl=https://open.weixin.qq.com
weChatApiUrl=https://api.weixin.qq.com
weChatMpUrl=https://mp.weixin.qq.com
# 自定义后台路径
ADMIN_SERVE_ROOT=/admin

28
src/service/.env.example Normal file
View File

@@ -0,0 +1,28 @@
# 端口
PORT=9520
# MySQL
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_DATABASE=chatgpt
DB_SYNC=true
# Redis
REDIS_PORT=6379
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_USER=
REDIS_DB=0
# 是否测试环境
ISDEV=false
# 自定义微信URL
weChatOpenUrl=https://open.weixin.qq.com
weChatApiUrl=https://api.weixin.qq.com
weChatMpUrl=https://mp.weixin.qq.com
# 自定义后台路径
ADMIN_SERVE_ROOT=/admin

35
src/service/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# 编译阶段
FROM node:20.14.0-alpine AS build
WORKDIR /app
COPY package*.json ./
# 设置环境变量来忽略一些警告
ENV NPM_CONFIG_LOGLEVEL=error
ENV NODE_OPTIONS=--max-old-space-size=4096
# 合并RUN命令更新依赖设置镜像源安装依赖然后清理
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
npm config set registry https://registry.npmmirror.com && \
apk add --no-cache --virtual .build-deps git && \
npm install -g npm@latest && \
npm install --production --no-optional --legacy-peer-deps && \
npm cache clean --force && \
apk del .build-deps && \
rm -rf /var/cache/apk/* /tmp/*
# 运行阶段
FROM node:20.14.0-alpine AS runner
ENV TZ="Asia/Shanghai" \
NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
EXPOSE 9520
CMD ["node", "--max-old-space-size=4096", "./dist/main.js"]

View File

@@ -0,0 +1,45 @@
version: '3.9'
services:
mysql:
image: mysql:8
command: --mysql-native-password=ON --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
# command: --default-authentication-plugin=caching_sha2_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: always
volumes:
- ./data/mysql/:/var/lib/mysql/
- ./sql/:/docker-entrypoint-initdb.d/ #数据库文件放此目录可自动导入
# ports:
# - "3306:3306"
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: "123456"
MYSQL_DATABASE: "chatgpt"
MYSQL_USER: "chatgpt"
MYSQL_PASSWORD: "123456"
redis:
image: redis
# command: --requirepass "12345678" # redis库密码,不需要密码注释本行
restart: always
# ports:
# - "6379:6379"
environment:
TZ: Asia/Shanghai # 指定时区
volumes:
- ./data/redis/:/data/
99ai:
build: ./
# image: vastxie/99ai:latest
container_name: 99ai
restart: always
ports:
- "9520:9520"
volumes:
- ./.env.docker:/app/.env
environment:
- TZ=Asia/Shanghai
depends_on:
- mysql
- redis

View File

@@ -1,6 +1,6 @@
{
"name": "99ai",
"version": "4.0.0",
"version": "4.1.0",
"description": "",
"author": "vastxie",
"private": true,

View File

@@ -0,0 +1,132 @@
import { Injectable, Logger } from '@nestjs/common';
import { GlobalConfigService } from '../globalConfig/globalConfig.service';
@Injectable()
export class netSearchService {
constructor(private readonly globalConfigService: GlobalConfigService) {}
async webSearchPro(prompt: string) {
try {
const { pluginUrl, pluginKey } =
await this.globalConfigService.getConfigs(['pluginUrl', 'pluginKey']);
if (!pluginUrl || !pluginKey) {
Logger.warn('搜索插件配置缺失');
return { searchResults: [] };
}
// 如果有多个 key随机选择一个
const keys = pluginKey.split(',').filter((key) => key.trim());
const selectedKey = keys[Math.floor(Math.random() * keys.length)];
const isBochaiApi = pluginUrl.includes('bochaai.com');
const isBigModelApi = pluginUrl.includes('bigmodel.cn');
const isTavilyApi = pluginUrl.includes('tavily.com');
Logger.log(
`[搜索] API类型: ${
isBochaiApi
? 'Bochai'
: isBigModelApi
? 'BigModel'
: isTavilyApi
? 'Tavily'
: '未知'
}`
);
Logger.log(`[搜索] 请求URL: ${pluginUrl}`);
Logger.log(`[搜索] 搜索关键词: ${prompt}`);
const requestBody = isBochaiApi
? {
query: prompt,
freshness: 'oneWeek',
summary: true,
}
: isTavilyApi
? {
query: prompt,
search_depth: 'basic',
include_answer: false,
include_raw_content: false,
include_images: false,
}
: {
tool: 'web-search-pro',
stream: false,
messages: [{ role: 'user', content: prompt }],
};
Logger.log(`[搜索] 请求参数: ${JSON.stringify(requestBody, null, 2)}`);
const response = await fetch(pluginUrl, {
method: 'POST',
headers: {
Authorization: selectedKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
Logger.error(`[搜索] 接口返回错误: ${response.status}`);
return { searchResults: [] };
}
const apiResult = await response.json();
Logger.log(`[搜索] 原始返回数据: ${JSON.stringify(apiResult, null, 2)}`);
let searchResults: any[] = [];
if (isBochaiApi) {
if (apiResult?.code === 200 && apiResult?.data?.webPages?.value) {
searchResults = apiResult.data.webPages.value.map((item: any) => ({
title: item?.name || '',
link: item?.url || '',
content: item?.summary || '',
icon: item?.siteIcon || '',
media: item?.siteName || '',
}));
}
} else if (isBigModelApi) {
if (apiResult?.choices?.[0]?.message?.tool_calls?.length > 0) {
for (const toolCall of apiResult.choices[0].message.tool_calls) {
if (Array.isArray(toolCall.search_result)) {
searchResults = toolCall.search_result.map((item: any) => ({
title: item?.title || '',
link: item?.link || '',
content: item?.content || '',
icon: item?.icon || '',
media: item?.media || '',
}));
break;
}
}
}
} else if (isTavilyApi) {
if (Array.isArray(apiResult?.results)) {
searchResults = apiResult.results.map((item: any) => ({
title: item?.title || '',
link: item?.url || '',
content: item?.content || '',
icon: '',
media: '',
}));
}
}
const formattedResult = searchResults.map((item, index) => ({
resultIndex: index + 1,
...item,
}));
Logger.log(
`[搜索] 格式化后的结果: ${JSON.stringify(formattedResult, null, 2)}`
);
return { searchResults: formattedResult };
} catch (fetchError) {
Logger.error('[搜索] 调用接口出错:', fetchError);
return { searchResults: [] };
}
}
}

View File

@@ -29,7 +29,7 @@ import { VerificationService } from '../verification/verification.service';
import { VerifycationEntity } from '../verification/verifycation.entity';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';
import { netSearchService } from '../ai/netSearch.service';
@Global()
@Module({
imports: [
@@ -67,6 +67,7 @@ import { ChatService } from './chat.service';
CogVideoService,
FluxDrawService,
AiPptService,
netSearchService,
],
exports: [ChatService],
})

View File

@@ -25,6 +25,7 @@ import { PluginEntity } from '../plugin/plugin.entity';
import { UploadService } from '../upload/upload.service';
import { UserService } from '../user/user.service';
import { UserBalanceService } from '../userBalance/userBalance.service';
import { netSearchService } from '../ai/netSearch.service';
@Injectable()
export class ChatService {
@@ -52,7 +53,8 @@ export class ChatService {
private readonly lumaVideoService: LumaVideoService,
private readonly cogVideoService: CogVideoService,
private readonly fluxDrawService: FluxDrawService,
private readonly aiPptService: AiPptService
private readonly aiPptService: AiPptService,
private readonly netSearchService: netSearchService
) {}
/* 有res流回复 没有同步回复 */
@@ -159,6 +161,7 @@ export class ChatService {
let usePrompt;
let useModelAvatar = '';
let usingPlugin;
let searchResult;
if (usingPluginId) {
usingPlugin = await this.pluginEntity.findOne({
@@ -207,19 +210,63 @@ export class ChatService {
}
} else {
const groupInfo = await this.chatGroupService.getGroupInfoFromId(groupId);
if (usingPlugin && usingPlugin.isSystemPlugin === 0) {
let pluginPrompt = '';
if (usingPlugin?.parameters === 'net-search') {
try {
pluginPrompt = await this.usePlugin(prompt, usingPlugin.parameters);
Logger.log(`插件返回结果: ${pluginPrompt}`, 'ChatService');
// 获取搜索结果
searchResult = await this.netSearchService.webSearchPro(prompt);
} catch (error) {
pluginPrompt = prompt; // 或者其他错误处理逻辑
Logger.error(`插件调用错误: ${error}`);
Logger.error('网络请求失败', error);
// 如果网络请求失败,可以设置一个默认值或备用数据
searchResult = null;
}
setSystemMessage = pluginPrompt;
const now = new Date();
const options = {
timeZone: 'Asia/Shanghai', // 设置时区为 'Asia/Shanghai'(北京时间)
year: 'numeric' as 'numeric',
month: '2-digit' as '2-digit',
day: '2-digit' as '2-digit',
hour: '2-digit' as '2-digit',
minute: '2-digit' as '2-digit',
hour12: false, // 使用24小时制
};
const currentDate = new Intl.DateTimeFormat('zh-CN', options).format(
now
);
// 将 searchResult 转换为 JSON 字符串
let searchPrompt = '';
if (searchResult) {
searchPrompt = JSON.stringify(searchResult, null, 2); // 格式化为漂亮的 JSON 字符串
}
// 设置系统消息
setSystemMessage = `
你的任务是根据用户的问题,通过下面的搜索结果提供更精确、详细、具体的回答。
请在适当的情况下在对应部分句子末尾标注引用的链接,使用[[序号resultIndex](链接地址link)]格式,同时使用多个链接可连续使用比如[[2](链接地址)][[5](链接地址)]
在回答的最后,使用 序号resultIndex.[title](链接地址link) 标记出所有的来源。
以下是搜索结果:
${searchPrompt}
在回答时,请注意以下几点:
- 现在时间是: ${currentDate}
- 并非搜索结果的所有内容都与用户的问题密切相关,你需要结合问题,对搜索结果进行甄别、筛选。
- 对于列举类的问题如列举所有航班信息尽量将答案控制在10个要点以内并告诉用户可以查看搜索来源、获得完整信息。优先提供信息完整、最相关的列举项如非必要不要主动告诉用户搜索结果未提供的内容。
- 对于创作类的问题(如写论文),请务必在正文的段落中引用对应的参考编号。你需要解读并概括用户的题目要求,选择合适的格式,充分利用搜索结果并抽取重要信息,生成符合用户要求、极具思想深度、富有创造力与专业性的答案。你的创作篇幅需要尽可能延长,对于每一个要点的论述要推测用户的意图,给出尽可能多角度的回答要点,且务必信息量大、论述详尽。
- 如果回答很长请尽量结构化、分段落总结。如果需要分点作答尽量控制在5个点以内并合并相关的内容。
- 对于客观类的问答,如果问题的答案非常简短,可以适当补充一到两句相关信息,以丰富内容。
- 你需要根据用户要求和回答内容选择合适、美观的回答格式,确保可读性强。
- 你的回答应该综合多个相关网页来回答,不能只重复引用一个网页。
- 除非用户要求,否则你回答的语言需要和用户提问的语言保持一致。
`;
// 获取当前的模型信息
currentRequestModelKey =
await this.modelsService.getCurrentModelKeyInfo(model);
} else if (usingPlugin?.parameters === 'mind-map') {
setSystemMessage =
'Please provide a detailed outline in Markdown format: Use a multi-level structure with at least 3-4 levels. Include specific solutions or steps under each topic. Make it suitable for creating mind maps. Provide only the outline content without any irrelevant explanations. Start directly with the outline, no introduction needed. Use the language I asked in. Note: Use #, ##, ### etc. for different heading levels. Use - or * for list items. Use bold, italic etc. to emphasize key points. Use tables, code blocks etc. as needed. Please provide a clear, content-rich Markdown format outline based on these requirements.';
currentRequestModelKey =
await this.modelsService.getCurrentModelKeyInfo(model);
// await this.chatLogService.checkModelLimits(req.user, model);
Logger.log(`使用插件预设: ${setSystemMessage}`, 'ChatService');
} else if (fileParsing) {
setSystemMessage = `以下是我提供给你的知识库:【${fileParsing}】,在回答问题之前,先检索知识库内有没有相关的内容,尽量使用知识库中获取到的信息来回答我的问题,以知识库中的为准。`;

View File

@@ -141,11 +141,6 @@ export class GlobalConfigService implements OnModuleInit {
`获取微信access_token失败、错误信息${errmsg}`,
'OfficialService'
);
} else {
throw new HttpException(
'请配置正确的秘钥、当前秘钥检测不通过!',
HttpStatus.BAD_REQUEST
);
}
return '';
}

View File

@@ -15,9 +15,6 @@ export class PluginEntity extends BaseEntity {
@Column({ comment: '插件是否启用 0禁用 1启用', default: 1 })
isEnabled: number;
@Column({ comment: '插件是否为系统插件 0否 1是', default: 0 })
isSystemPlugin: number;
@Column({ comment: '调用参数', type: 'text' })
parameters: string;

View File

@@ -38,28 +38,19 @@ export class PluginService {
// 处理插件列表
const processedRows = await Promise.all(
rows.map(async (plugin) => {
if (plugin.isSystemPlugin === 1) {
try {
const parameters = await this.modelsService.getCurrentModelKeyInfo(
plugin.parameters
);
const deductType = parameters.deductType;
// 将 parameters 和 deductType 作为附加参数返回
return {
...plugin,
deductType,
};
} catch (error) {
// 出现异常时返回 deductType 为 0
return {
...plugin,
deductType: 0,
};
}
} else {
// 非系统插件,直接返回 deductType 为 0
try {
const parameters = await this.modelsService.getCurrentModelKeyInfo(
plugin.parameters
);
const deductType = parameters.deductType;
// 将 parameters 和 deductType 作为附加参数返回
return {
...plugin,
deductType,
};
} catch (error) {
// 出现异常时返回 deductType 为 0
return {
...plugin,
deductType: 0,
@@ -77,16 +68,8 @@ export class PluginService {
// 创建插件
async createPlugin(body: any) {
const {
name,
pluginImg,
description,
isEnabled,
isSystemPlugin,
parameters,
sortOrder,
} = body;
const { name, pluginImg, description, isEnabled, parameters, sortOrder } =
body;
// 检查插件名称是否存在
const existingPlugin = await this.PluginEntity.findOne({
@@ -101,9 +84,7 @@ export class PluginService {
name,
pluginImg,
description,
isEnabled: isEnabled !== undefined ? isEnabled : 1, // 默认启用
isSystemPlugin: isSystemPlugin !== undefined ? isSystemPlugin : 0, // 默认非系统插件
parameters,
sortOrder: sortOrder !== undefined ? sortOrder : 0, // 默认排序值
});
@@ -120,7 +101,6 @@ export class PluginService {
pluginImg,
description,
isEnabled,
isSystemPlugin,
parameters,
sortOrder,
} = body;
@@ -147,10 +127,6 @@ export class PluginService {
existingPlugin.description = description;
existingPlugin.isEnabled =
isEnabled !== undefined ? isEnabled : existingPlugin.isEnabled;
existingPlugin.isSystemPlugin =
isSystemPlugin !== undefined
? isSystemPlugin
: existingPlugin.isSystemPlugin;
existingPlugin.parameters = parameters;
existingPlugin.sortOrder =
sortOrder !== undefined ? sortOrder : existingPlugin.sortOrder;