改进 聊天和左侧菜单

This commit is contained in:
Twilight 2024-09-12 11:16:30 +08:00
parent 92da9181e1
commit 086dd9979a
17 changed files with 480 additions and 41 deletions

View File

@ -1,4 +0,0 @@
node_modules
dist
out
.gitignore

View File

@ -1,17 +1,16 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@electron-toolkit',
'@electron-toolkit/eslint-config-ts/eslint-recommended',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier'
"eslint:recommended",
"plugin:vue/vue3-recommended",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier",
],
rules: {
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off'
}
}
"vue/require-default-prop": "off",
"vue/multi-word-component-names": "off",
},
ignores: ["src/typed-router.d.ts", "node_modules", "dist", "out", ".gitignore"],
};

View File

@ -31,11 +31,17 @@
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.26.0",
"eslint": "^9.10.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-n": "^17.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^7.1.0",
"eslint-plugin-vue": "^9.28.0",
"naive-ui": "^2.39.0",
"postcss": "^8.4.45",
"prettier": "^3.3.2",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2",

2
src/components.d.ts vendored
View File

@ -12,6 +12,8 @@ declare module 'vue' {
ChatMenu: typeof import('./components/ChatMenu.vue')['default']
Container: typeof import('./components/Container.vue')['default']
copy: typeof import('./components/AssistantMenu.vue')['default']
LeftSetting: typeof import('./components/LeftSetting.vue')['default']
LeftSettings: typeof import('./components/LeftSettings.vue')['default']
Menu: typeof import('./components/Menu.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@ -1,5 +1,6 @@
<template>
<n-list hoverable clickable>
<div v-show="!loaded">正在载入</div>
<n-list hoverable clickable v-show="loaded" class="select-none">
<template #header>
<div>
<span class="text-xl">切换助理</span>
@ -14,14 +15,11 @@
</template>
<script setup lang="ts">
import { useRoute } from "vue-router";
import { useUserStore } from "../stores/user";
import { updateAll } from "../plugins/update/update";
import { useAssistantStore } from "../stores/assistants";
const route = useRoute();
const currentRoute: any = computed(() => route.name);
const collapsed = ref(false);
const loaded = ref(false);
const userStore = useUserStore();
const assistantStore = useAssistantStore();
@ -35,6 +33,7 @@ watch(
);
function update() {
updateAll();
loaded.value = true
}
update();

View File

@ -2,7 +2,8 @@
// import router from '../plugins/router';
import Header from "../layouts/Header.vue";
import { useUserStore } from "../stores/user";
import { useRoute } from "vue-router";
const route = useRoute();
const userStore = useUserStore();
// const currentRoute = computed(() => router.currentRoute.value.name)
</script>
@ -14,7 +15,7 @@ const userStore = useUserStore();
></Header>
<div class="p-4">
<router-view v-slot="{ Component }">
<router-view v-slot="{ Component }" :key="route.path">
<transition mode="out-in" name="fade">
<div>
<component :is="Component" />

View File

@ -0,0 +1,53 @@
<template>
<n-tabs type="segment" animated>
<n-tab-pane name="chap1" tab="对话">
<n-list hoverable clickable>
<n-list-item
v-for="c in chatStore.chats"
:key="c.id"
:class="c.id == chatStore.currentChatId ? 'text-primary' : ''"
@click="viewChat(c.id ?? 0)"
>
<n-thing> {{ c.name }} </n-thing>
</n-list-item>
</n-list>
</n-tab-pane>
<n-tab-pane name="chap2" tab="助理">
威尔着火了快来帮忙我听到女朋友大喊现在一个难题在我面前是恢复一个重要的
Amazon 服务还是救公寓的火<br /><br />
我的脑海中忽然出现了 Amazon
著名的领导力准则客户至上有很多的客户还依赖我们的服务我不能让他们失望所以着火也不管了女朋友喊我也无所谓我开始
debug 这个线上问题
</n-tab-pane>
</n-tabs>
</template>
<script setup lang="ts">
import { NMenu } from "naive-ui";
import { useRoute } from "vue-router";
import { leftMenuOptions } from "../plugins/menus/left";
import { ChatboxOutline } from "@vicons/ionicons5";
import getApi from "../plugins/api";
import { useChatStore } from "../stores/chat";
import router from "@/router";
const route = useRoute();
// @ts-ignore
const chatId = route.params.id as number;
const currentRoute: any = computed(() => route.name);
const collapsed = ref(false);
const chatStore = useChatStore();
async function getChats() {
chatStore.chats = (await getApi().Chat.apiV1ChatsGet()).data.data;
}
const viewChat = (chatId: number) => {
router.push("/chat/" + chatId);
};
getChats();
</script>

View File

@ -13,8 +13,8 @@
<span class="font-xl ml-2">对话列表</span>
</div>
</template>
<n-list-item v-for="i in 10" :key="i">
<n-thing> 对话 </n-thing>
<n-list-item v-for="c in chatStore.chats" :key="c.id">
<n-thing> {{ c.name }} </n-thing>
</n-list-item>
</n-list>
</template>
@ -24,10 +24,20 @@ import { NMenu } from "naive-ui";
import { useRoute } from "vue-router";
import { leftMenuOptions } from "../plugins/menus/left";
import { ChatboxOutline } from "@vicons/ionicons5";
import getApi from "../plugins/api";
import { useChatStore } from "../stores/chat";
const route = useRoute();
const currentRoute: any = computed(() => route.name);
const collapsed = ref(false);
const chatStore = useChatStore();
async function getChats() {
chatStore.chats = (await getApi().Chat.apiV1ChatsGet()).data.data;
}
getChats();
</script>

View File

@ -20,7 +20,7 @@ const menuCollapsed = ref({
<template>
<n-layout position="absolute" :has-sider="true">
<n-layout-sider
<!-- <n-layout-sider
v-if="userStore.logined && !isMobile"
:collapsed-width="0"
:native-scrollbar="false"
@ -34,7 +34,7 @@ const menuCollapsed = ref({
@expand="menuCollapsed.left = false"
>
<Menu v-show="!isMobile"></Menu>
</n-layout-sider>
</n-layout-sider> -->
<n-layout-content>
<!-- <Guest v-if="!userStore.logined && currentRoute != '/auth/login'" />
<Container v-else /> -->

View File

@ -4,7 +4,7 @@
<n-grid cols="2" class="header-height">
<n-grid-item class="flex items-center justify-start mr-1.5">
<!-- 左侧 -->
<n-popover
<!-- <n-popover
placement="bottom"
trigger="click"
style="padding: 0; width: 288px"
@ -16,7 +16,25 @@
</n-icon>
</template>
<Menu></Menu>
</n-popover>
</n-popover> -->
<n-drawer
v-model:show="showDrawer"
resizable
:default-width="width"
placement="left"
>
<n-drawer-content>
<LeftSettings></LeftSettings>
</n-drawer-content>
</n-drawer>
<n-icon
size="20"
style="margin-left: 12px"
@click="showDrawer = true"
>
<menu-outline />
</n-icon>
<!-- 更新状态 -->
<div v-show="appStore.updating">正在更新数据</div>
@ -72,6 +90,16 @@ const userStore = useUserStore();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const appStore = useAppStore();
const showDrawer = ref(false);
const width = ref(200);
// width
if (isMobile.value) {
//
width.value = window.innerWidth - 100;
} else {
width.value = "40%";
}
const userPlacement = ref("bottom");
if (isMobile.value) {

View File

@ -0,0 +1,322 @@
<template>
<div>
<h3 class="mb-3">聊天记录</h3>
<v-card
v-for="message in messages.data"
:key="message.id"
class="mx-auto mt-3"
width="100%"
>
<template #title>
<div class="font-weight-black">
<div v-if="message.role == 'assistant'">AI</div>
<div v-else-if="message.role == 'system'">系统</div>
<div v-else-if="message.role == 'file'" class="text-right">文件</div>
<div v-else-if="message.role == 'image'" class="text-right">图片</div>
<div v-else-if="message.role == 'user'" class="text-right">用户</div>
<div v-else>
{{ message.role }}
</div>
</div>
</template>
<v-card-text class="bg-surface-light pt-4">
<div
v-if="message.role == 'assistant' || message.role == 'system'"
class="text-left"
>
<vue-markdown :source="message.content" />
</div>
<div v-else-if="message.role == 'user'" class="text-right">
<vue-markdown :source="message.content" />
</div>
<div v-else-if="message.role == 'image'" class="text-right">
<img
:src="fileBaseUrl + '/' + message.content + '/download'"
width="30%"
/>
</div>
<div v-else-if="message.role == 'file'" class="text-right">
其他文件<a :href="fileBaseUrl + '/' + message.content + '/download'"
>下载</a
>
</div>
<div v-else>
{{ message.content }}
</div>
</v-card-text>
</v-card>
<div class="mt-3">
<div v-if="toolError" class="mb-3">
<v-alert
density="compact"
text="这个工具出现了异常,这应该不是我们的问题,如果你是此工具的开发者,请打开开发者控制台查看具体错误。"
:title="'工具 ' + toolName + ' 出现异常'"
type="warning"
></v-alert>
</div>
<div v-show="toolCalling">
<v-progress-circular
color="primary"
indeterminate
:size="16"
></v-progress-circular>
正在执行 {{ toolName }}
</div>
<v-text-field
v-model="input"
label="输入消息"
@keyup.enter="sendMessage"
></v-text-field>
<v-file-input v-model="fileUpload" label="选择文件"></v-file-input>
<v-btn color="primary" @click="sendMessage">发送</v-btn>
<v-btn
class="ml-2"
color="primary"
:loading="uploading"
@click="uploadFile"
>上传文件</v-btn
>
<v-btn class="ml-2" color="primary" @click="clearMessages">清空</v-btn>
<v-btn class="ml-2" color="primary" @click="deleteChat">删除</v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import useApi from "@/plugins/api";
import router from "@/router";
import { useChatStore } from "@/stores/chat";
import config from "@/config/config";
const chatStore = useChatStore();
const api = useApi();
// @ts-ignore
const chatId = useRoute().params.id as number;
chatStore.currentChatId = chatId;
const messages: Ref<any> = ref({
data: [],
});
const input = ref("");
const toolName = ref("");
const toolError = ref(false);
const toolCalling = ref(false);
const fileUpload = ref();
const uploading = ref(false);
const fileBaseUrl = config.backend + "/api/v1/files";
document.addEventListener("paste", function (event) {
const items = event.clipboardData && event.clipboardData.items;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
fileUpload.value = items[i].getAsFile();
break;
}
}
}
});
function sendMessage() {
if (input.value !== "") {
toolError.value = false;
api.ChatMessage.apiV1ChatsIdMessagesPost(chatId, {
message: input.value,
role: "user",
})
.then((res) => {
const newMessage = {
content: input.value,
role: "user",
};
if (messages.value.data == null) {
messages.value.data = [newMessage];
} else {
messages.value.data?.push(newMessage);
}
const streamId = res.data.data?.stream_id;
if (streamId) {
streamChat(streamId);
}
})
.catch((err) => {
// if 409
if (err.response.status === 409) {
const streamId = err.response.data.data?.stream_id;
if (streamId) {
streamChat(streamId);
}
}
});
}
}
function streamChat(streamId: String) {
const url = api.conf.basePath + "/api/v1/stream/" + streamId;
const evtSource = new EventSource(url);
let messageAdded = false;
//
window.scrollTo(0, document.body.scrollHeight);
let i = 0;
evtSource.addEventListener("data", (e) => {
if (e.data === "[DONE]") {
evtSource.close();
return;
}
const data = JSON.parse(e.data);
let append = true;
switch (data.state) {
case "tool_calling":
toolCalling.value = true;
toolName.value =
data.tool_call_message.tool_name +
" 中的 " +
data.tool_call_message.function_name;
break;
case "tool_response":
setTimeout(() => {
toolName.value = "";
toolCalling.value = false;
}, 300);
break;
case "tool_failed":
toolName.value =
data.tool_response_message.tool_name +
" 中的 " +
data.tool_response_message.function_name;
toolError.value = true;
append = false;
setTimeout(() => {
toolCalling.value = false;
}, 300);
break;
case "chunk":
if (!messageAdded) {
const newMessage = {
content: "",
role: "assistant",
};
if (messages.value.data == null) {
messages.value.data = [newMessage];
} else {
// add to messages
messages.value.data?.push(newMessage);
}
messageAdded = true;
append = true;
// set index
i = (messages.value.data?.length ?? 1) - 1;
}
}
if (append && messageAdded) {
// @ts-ignore
messages.value.data[i].content += data.content;
}
});
// close
evtSource.addEventListener("close", () => {
evtSource.close();
});
}
const getMessages = () => {
api.ChatMessage.apiV1ChatsIdMessagesGet(chatId).then((res) => {
messages.value.data = [];
res.data.data?.forEach((message: any) => {
if (message.role === "file") {
// mime_type image/
if (message.user_file) {
if (message.user_file.file.mime_type.startsWith("image/")) {
message.role = "image";
message.content = message.user_file.file.id;
}
} else if (message.file.mime_type.startsWith("image/")) {
message.role = "image";
message.content = message.file.id;
}
messages.value.data?.push(message);
} else if (message.role === "assistant" || message.role === "user") {
messages.value.data?.push(message);
}
});
});
};
const clearMessages = () => {
api.ChatMessage.apiV1ChatsIdClearPost(chatId).then(() => {
getMessages();
});
};
const deleteChat = () => {
api.Chat.apiV1ChatsIdDelete(chatId).then(() => {
api.Chat.apiV1ChatsGet().then((r) => {
chatStore.chats = r.data.data;
});
router.push("/assistants");
});
};
const uploadFile = () => {
if (!fileUpload.value) {
return;
}
uploading.value = true;
api.ChatMessage.apiV1ChatsIdFilesPost(
chatId,
{
file: fileUpload.value,
url: "",
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
)
.then((r) => {
//
if (r.status === 200 || r.status === 201) {
fileUpload.value = null;
getMessages();
}
})
.catch((err) => {
alert(err.response.data.message);
})
.finally(() => {
uploading.value = false;
});
};
getMessages();
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>聊天记录 {{ chatId }}</div>
</template>
<script lang="ts" setup>
// @ts-ignore
const chatId = useRoute().params.id as number;
// chatStore.currentChatId = chatId;
</script>

View File

@ -20,6 +20,7 @@ interface Api {
Tool: ToolApi;
ChatMessage: ChatMessageApi;
ChatPublic: ChatPublicApi;
conf: Configuration
}
let api: Api | null = null; // 使用联合类型来表示初始状态可能是 null
@ -49,6 +50,7 @@ const getApi = () => {
Tool: new ToolApi(conf, undefined, axios),
ChatMessage: new ChatMessageApi(conf, undefined, axios),
ChatPublic: new ChatPublicApi(conf, undefined, axios),
conf: conf
};
return api;

View File

@ -4,6 +4,7 @@ import { EntityChat } from "../api";
export const useChatStore = defineStore("chats", {
persist: false,
state: () => ({
currentChatId: 0,
chats: <EntityChat[] | undefined>[],
}),
});

View File

@ -23,6 +23,8 @@ declare module 'vue-router/auto-routes' {
'/auth/continue': RouteRecordInfo<'/auth/continue', '/auth/continue', Record<never, never>, Record<never, never>>,
'/auth/login': RouteRecordInfo<'/auth/login', '/auth/login', Record<never, never>, Record<never, never>>,
'/auth/logout': RouteRecordInfo<'/auth/logout', '/auth/logout', Record<never, never>, Record<never, never>>,
'/chat/[id]/': RouteRecordInfo<'/chat/[id]/', '/chat/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/chat/[id]/index copy': RouteRecordInfo<'/chat/[id]/index copy', '/chat/:id/index copy', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/guest/': RouteRecordInfo<'/guest/', '/guest', Record<never, never>, Record<never, never>>,
'/test': RouteRecordInfo<'/test', '/test', Record<never, never>, Record<never, never>>,
'/test2': RouteRecordInfo<'/test2', '/test2', Record<never, never>, Record<never, never>>,

View File

@ -17,7 +17,16 @@
"skipLibCheck": true,
"types": [
"unplugin-vue-router/client"
]
],
"paths": {
"@/*": [
"./src/*"
]
},
"allowJs": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"forceConsistentCasingInFileNames": true,
},
"include": [
"src/**/*.ts",
@ -30,4 +39,4 @@
"path": "./tsconfig.node.json"
}
]
}
}

View File

@ -1,13 +1,13 @@
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import VueRouter from "unplugin-vue-router/vite"
import Layouts from "vite-plugin-vue-layouts"
import Components from "unplugin-vue-components/vite"
import AutoImport from "unplugin-auto-import/vite"
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import VueRouter from "unplugin-vue-router/vite";
import Layouts from "vite-plugin-vue-layouts";
import Components from "unplugin-vue-components/vite";
import AutoImport from "unplugin-auto-import/vite";
// import { resolve } from "path";
// const rootPath = new URL(".", import.meta.url).pathname;
import { fileURLToPath, URL } from "node:url"
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
@ -54,4 +54,4 @@ export default defineConfig({
port: 5173,
strictPort: true,
},
})
});