完成机器人选择功能

master
落雨楓 2 years ago
parent 5ab7be5adc
commit a861b2a635

@ -1,7 +1,7 @@
import { RemoteError } from "@/errors/remoteError"
import { toolkitApi } from "@/request/toolkit-api";
import { useUserStore } from "@/stores"
import { ChatCompleteChunkInfo, ExtractDocInfo } from "@/types/base";
import { BotPersonaInfo, ChatCompleteChunkInfo, ExtractDocInfo } from "@/types/base";
import { getWebSocketApiUrl } from "@/utils/request";
export type IndexPageParams = {
@ -67,6 +67,23 @@ export type GetPointCostParams = {
extract_limit?: number,
}
export type GetBotPersonaListParams = {
page?: number,
category_id?: number,
}
export type GetBotPersonaListResponse = {
list: BotPersonaInfo[],
page_count: number,
}
export type GetBotPersonaInfoParams = {
id?: number,
bot_id?: string,
}
export type GetBotPersonaInfoResponse = BotPersonaInfo
export const chatCompleteApi = {
indexPage(params: IndexPageParams, onProgress: IndexPageOnProgress): Promise<IndexPageResponse> {
return new Promise((resolve, reject) => {
@ -120,6 +137,16 @@ export const chatCompleteApi = {
return data.data?.point_cost ?? -1
},
async getBotPersonaList(params: GetBotPersonaListParams): Promise<GetBotPersonaListResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/persona/list`, { params })
return data.data
},
async getBotPersonaInfo(params: GetBotPersonaInfoParams): Promise<GetBotPersonaInfoResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/persona/info`, { params })
return data.data
},
async forkConversation(params: ForkConversationParams): Promise<ForkConversationResponse> {
const { data } = await toolkitApi.post(`/chatcomplete/conversation/fork`, params)
return data.data
@ -129,7 +156,8 @@ export const chatCompleteApi = {
conversationId?: number,
extractLimit?: number,
inCollection?: boolean,
editMessageId?: [number, string]
editMessageId?: [number, string],
botId?: string,
} = {}): Promise<StartChatCompleteResponse> {
let params: any = {
title: title,
@ -147,6 +175,9 @@ export const chatCompleteApi = {
if (args.editMessageId) {
params.edit_message_id = args.editMessageId.join(',')
}
if (args.botId) {
params.bot_id = args.botId
}
const { data } = await toolkitApi.post('/chatcomplete/message', params)
return data.data
},

@ -9,6 +9,7 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
BotPersonaList: typeof import('./components/BotPersonaList.vue')['default']
ChatCompleteSettingsModal: typeof import('./components/ChatCompleteSettingsModal.vue')['default']
ChatMessage: typeof import('./components/ChatMessage.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp']
@ -31,9 +32,6 @@ declare module '@vue/runtime-core' {
IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
IonItem: typeof import('@ionic/vue')['IonItem']
IonItemOption: typeof import('@ionic/vue')['IonItemOption']
IonItemOptions: typeof import('@ionic/vue')['IonItemOptions']
IonItemSliding: typeof import('@ionic/vue')['IonItemSliding']
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList']
IonListHeader: typeof import('@ionic/vue')['IonListHeader']
@ -58,6 +56,7 @@ declare module '@vue/runtime-core' {
MarkdownParser: typeof import('./components/MarkdownParser.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SelectBotPersonaModal: typeof import('./components/SelectBotPersonaModal.vue')['default']
TypeaheadModal: typeof import('./components/TypeaheadModal.vue')['default']
}
}

@ -8,6 +8,7 @@ export default {
import { createOutline, reloadOutline, gitNetworkOutline } from 'ionicons/icons'
const props = defineProps<{
msgId?: string,
sender: string,
avatar: string,
content: string,
@ -54,7 +55,7 @@ const displayTime = computed(() => {
<span class="message-sender">{{ props.sender }}</span>
<span class="message-time">{{ displayTime }}</span>
</div>
<div class="header-right">
<div class="header-right" v-if="props.msgId">
<ion-button fill="clear" size="small" color="primary" v-if="props.edit" @click="emit('clickEdit')">
<ion-icon slot="start" :icon="createOutline"></ion-icon>
<ion-label>编辑</ion-label>

@ -0,0 +1,69 @@
<script lang="ts">
export default {
name: 'SelectBotPersonaModal',
}
</script>
<script lang="ts" setup>
import { api } from '@/api'
import { BotPersonaInfo } from '@/types/base'
import { checkmarkCircleOutline } from 'ionicons/icons'
const DEFAULT_AVATAR = '/images/assistant-avatar.png'
const emit = defineEmits<{
(event: 'selectedPersona', persona: BotPersonaInfo): any
}>()
const state = reactive({
loading: true,
personaList: [] as BotPersonaInfo[],
})
const loadPersonaList = async () => {
try {
let res = await api.chat.getBotPersonaList({})
state.personaList = res.list
} catch (err: any) {
console.error(err)
}
}
onMounted(async () => {
await loadPersonaList()
state.loading = false
})
</script>
<template>
<ion-content>
<ion-list inset lines="full">
<ion-item v-if="state.loading">
<div class="spinner-wrapper">
<ion-spinner name="lines" />
</div>
</ion-item>
<ion-item v-else v-for="persona in state.personaList" :key="persona.id" button :detail="false"
:disabled="persona.bot_id === 'default'"
@click="emit('selectedPersona', persona)">
<ion-avatar slot="start">
<img :src="persona.bot_avatar ?? DEFAULT_AVATAR" />
</ion-avatar>
<ion-label>
<h2>{{ persona.bot_name }}</h2>
<p>{{ persona.bot_description }}</p>
</ion-label>
<ion-icon slot="end" v-if="persona.bot_id === 'default'" :icon="checkmarkCircleOutline"></ion-icon>
</ion-item>
</ion-list>
</ion-content>
</template>
<style lang="scss" scoped>
.spinner-wrapper {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>

@ -160,6 +160,9 @@ watch(() => props.visible, (value, oldValue) => {
<ion-list :inset="true" lines="full">
<ion-item v-for="item in filteredItems" @click="onSelectedItem(item)" :key="item.key" button
:detail="false">
<ion-avatar v-if="item.icon" slot="start">
<img :src="item.icon" />
</ion-avatar>
<ion-label>
<h2>{{ item.title }}</h2>
<p v-if="item.subtitle">{{ item.subtitle }}</p>

@ -352,48 +352,29 @@ onMounted(async () => {
<ion-list-header v-if="menuItemList.length === 0">
<ion-label class="conversation-list-title">这里还什么都没有呢</ion-label>
</ion-list-header>
<ion-item-sliding v-for="(p, i) in menuItemList" :key="p.id" :disabled="state.editingMode">
<ion-item-options side="start">
<ion-item-option v-if="!p.pinned" color="secondary" aria-label=""
@click="pinConversation(p.id, true)">
<ion-icon slot="icon-only" :icon="heartOutline"></ion-icon>
</ion-item-option>
<ion-item-option v-else color="secondary" aria-label="" @click="pinConversation(p.id, false)">
<ion-icon slot="icon-only" :icon="heart"></ion-icon>
</ion-item-option>
</ion-item-options>
<ion-item @click.prevent button :detail="false" lines="full" class="hydrated"
:class="{ selected: p.id === conversationStore.currentId }"
@contextmenu.prevent="showMenu($event, p, 'event')">
<ion-checkbox slot="start" v-if="state.editingMode"
:checked="state.selectedConversationIds.includes(p.id)" @click="onEditToggleSelectConversation(p.id)"
aria-label="选择编辑项目"></ion-checkbox>
<ion-menu-toggle :auto-hide="false">
<ion-label @click="onSelectConversation(p)">
<p class="conversation-page-title">{{ p.page_title || '' }}</p>
<div class="conversation-list-item-header">
<ion-icon v-if="p.icon" :icon="p.icon" size="small"></ion-icon>
<h2>{{ p.title || '无标题' }}</h2>
</div>
<p>{{ p.description || ' ' }}</p>
</ion-label>
</ion-menu-toggle>
<ion-button slot="end" fill="clear" title="选项" v-if="!state.editingMode"
@click="showMenu($event, p, 'trigger')">
<ion-icon slot="icon-only" :color="p.id === conversationStore.currentId ? 'light' : 'primary'"
:icon="ellipsisHorizontalCircleOutline"></ion-icon>
</ion-button>
</ion-item>
<ion-item-options side="end" @ion-swipe="deleteConversation(p.id)">
<ion-item-option color="success" aria-label="" @click="renameConversation(p.id)">
<ion-icon slot="start" :icon="pencilOutline"></ion-icon>
<ion-label>更改标题</ion-label>
</ion-item-option>
<ion-item-option color="danger" aria-label="" expandable @click="deleteConversation(p.id)">
<ion-icon slot="icon-only" :icon="trashOutline"></ion-icon>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
<ion-item v-for="(p, i) in menuItemList" :key="p.id"
button :detail="false" lines="full" class="hydrated" @click.prevent
:class="{ selected: p.id === conversationStore.currentId }"
@contextmenu.prevent="showMenu($event, p, 'event')">
<ion-checkbox slot="start" v-if="state.editingMode"
:checked="state.selectedConversationIds.includes(p.id)" @click="onEditToggleSelectConversation(p.id)"
aria-label="选择编辑项目"></ion-checkbox>
<ion-menu-toggle :auto-hide="false">
<ion-label @click="onSelectConversation(p)">
<p class="conversation-page-title">{{ p.page_title || '' }}</p>
<div class="conversation-list-item-header">
<ion-icon v-if="p.icon" :icon="p.icon" size="small"></ion-icon>
<h2>{{ p.title || '无标题' }}</h2>
</div>
<p>{{ p.description || ' ' }}</p>
</ion-label>
</ion-menu-toggle>
<ion-button slot="end" fill="clear" title="选项" v-if="!state.editingMode"
@click="showMenu($event, p, 'trigger')">
<ion-icon slot="icon-only" :color="p.id === conversationStore.currentId ? 'light' : 'primary'"
:icon="ellipsisHorizontalCircleOutline"></ion-icon>
</ion-button>
</ion-item>
</ion-list>
<ion-popover dismiss-on-select :is-open="state.showMenu" :event="state.showMenuTriggerEvent"
:reference="state.showMenuPosition" @didDismiss="state.showMenu = false">

@ -33,9 +33,23 @@ export type ChatCompleteMessage = {
export type ChatCompleteChunkInfo = ChatCompleteMessage[]
export type BotPersonaInfo = {
id: number,
bot_id: string,
bot_name: string,
bot_avatar?: string,
bot_description?: string,
category_id: number,
system_prompt: string,
message_log: ChatCompleteMessage[],
default_question?: string,
updated_at: number,
}
export type TypeaheadItem = {
key: any;
title: string;
subtitle?: string;
value: any;
};
key: any,
title: string,
subtitle?: string,
icon?: string,
value: any,
}

@ -1,3 +1,5 @@
export const DEFAULT_BOT_AVATAR = '/images/assistant-avatar.png'
export function setPageTitle(...title: string[]) {
let newTitle = title.join(' - ')
if (newTitle === '') {
@ -5,4 +7,8 @@ export function setPageTitle(...title: string[]) {
} else {
document.title = newTitle + ' - ' + import.meta.env.VITE_APP_NAME
}
}
export function secondTimestamp() {
return Math.floor(Date.now() / 1000)
}

@ -12,7 +12,7 @@ import {
} from '@ionic/vue'
import { showConfirm, showError, showHelpToast } from '@/utils/dialog'
import { ChatCompleteChunkInfo } from '@/types/base'
import { setPageTitle } from '@/utils/other'
import { DEFAULT_BOT_AVATAR, secondTimestamp, setPageTitle } from '@/utils/other'
import { pinConversation } from '@/utils/actions'
import { moduleConversationRoute } from '@/types/enum'
import { IonRouterOutletInstance } from '@/types/instance'
@ -42,6 +42,11 @@ const state = reactive({
conversationTitle: '加载中...',
conversationId: 0,
pinned: false,
personaLoaded: false,
botId: '',
botName: '',
botAvatar: '',
conversationChunkIdList: [] as number[],
messageChunkList: [] as ConversationChunkInfo[],
@ -73,6 +78,8 @@ if (route.params.id) {
state.conversationId = parseInt(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id)
}
const isNewConversaion = computed(() => state.conversationId === 0)
// ============================================================================
//
// ============================================================================
@ -100,12 +107,14 @@ const onSendMessage = async () => {
contentEl?.scrollToBottom()
ignoreAutoScroll = false
state.personaLoaded = true
state.conversationLoading = true
try {
let question = state.formMessage
const startRes = await api.chat.startChatComplete(pageStore.title, question, {
conversationId: state.conversationId,
editMessageId: state.editingId || undefined,
botId: state.botId,
inCollection: settingsStore.collectionMode,
extractLimit: settingsStore.docExtractLimit
})
@ -181,7 +190,7 @@ const onSendMessage = async () => {
}
if (startRes.conversation_id !== state.conversationId) {
if (state.conversationId === 0) {
if (isNewConversaion.value) {
//
conversationStore.list.push({
id: startRes.conversation_id,
@ -323,10 +332,14 @@ const loadPrevConversationChunk = (): Promise<void> => {
const loadConversation = async () => {
try {
if (state.conversationId === 0) {
if (isNewConversaion.value) {
//
state.conversationTitle = '未命名对话'
state.conversationLoading = true
state.botId = route.query.botId as string ?? 'default'
await loadBotPersona()
} else {
state.personaLoaded = true
state.messageChunkList = []
state.errorMessage = null
state.formMessage = ''
@ -346,6 +359,9 @@ const loadConversation = async () => {
state.conversationTitle = conversationInfo.title ?? '未命名对话'
state.pinned = conversationInfo.pinned
state.botId = conversationInfo.extra.bot_id ?? 'default'
await loadBotPersona()
state.conversationChunkIdList = await api.chat.getConversationChunkList({
id: state.conversationId
@ -387,6 +403,37 @@ const onInfiniteScrollLoad = async (event: InfiniteScrollCustomEvent) => {
await event.target.complete()
}
// ============================================================================
//
// ============================================================================
const loadBotPersona = async () => {
if (state.personaLoaded) return
state.personaLoaded = true
try {
let personaInfo = await api.chat.getBotPersonaInfo({
bot_id: state.botId
})
if (isNewConversaion.value) {
//
state.messageChunkList.push({
id: 0,
message_data: personaInfo.message_log?.map((message, index) => ({
...message,
id: '',
time: secondTimestamp()
})) ?? []
})
state.formMessage = personaInfo.default_question ?? ''
}
state.botName = personaInfo.bot_name
state.botAvatar = personaInfo.bot_avatar ?? DEFAULT_BOT_AVATAR
} catch (err: any) {
console.error(err)
}
}
// ============================================================================
//
// ============================================================================
@ -753,19 +800,21 @@ watch(() => conversationInfo.value, (currentConversation) => {
<ion-infinite-scroll-content loadingText="加载中..." loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
<!-- 消息列表 -->
<div class="message-chunk" v-for="chunk in ascMessageChunkList" :key="chunk.id">
<template v-for="message, index in chunk.message_data" :key="index">
<chat-message v-if="message.role == 'user'" :sender="userStore.displayName" :avatar="userStore.displayAvatar"
:content="message.content" :time="message.time"
:edit="chunk.id == state.lastUserMsgId[0] && message.id == state.lastUserMsgId[1]"
@click-edit="onEditMessage(chunk.id, message.id)"></chat-message>
<chat-message v-else-if="message.role == 'assistant'" sender="写作助手" avatar="/images/assistant-avatar.png"
:content="message.content" :time="message.time" assistant :cursor="message.streaming"
:change="chunk.id == state.lastAssistantMsgId[0] && message.id == state.lastAssistantMsgId[1]"
@click-create-branch="onCreateBranch(chunk.id, message.id)"
@click-change="onChangeResponse(chunk.id, message.id)"></chat-message>
</template>
</div>
<transition-group name="fade">
<div v-for="chunk in ascMessageChunkList" :key="chunk.id" class="message-chunk">
<template v-for="message, index in chunk.message_data" :key="index">
<chat-message v-if="message.role == 'user'" :sender="userStore.displayName" :avatar="userStore.displayAvatar"
:content="message.content" :time="message.time" :msg-id="message.id"
:edit="chunk.id == state.lastUserMsgId[0] && message.id == state.lastUserMsgId[1]"
@click-edit="onEditMessage(chunk.id, message.id)"></chat-message>
<chat-message v-else-if="message.role == 'assistant'" :sender="state.botName" :avatar="state.botAvatar"
:content="message.content" :time="message.time" assistant :cursor="message.streaming" :msg-id="message.id"
:change="chunk.id == state.lastAssistantMsgId[0] && message.id == state.lastAssistantMsgId[1]"
@click-create-branch="onCreateBranch(chunk.id, message.id)"
@click-change="onChangeResponse(chunk.id, message.id)"></chat-message>
</template>
</div>
</transition-group>
</ion-content>
<ion-footer :translucent="true">
@ -799,13 +848,13 @@ watch(() => conversationInfo.value, (currentConversation) => {
</ion-footer>
<chat-complete-settings-modal v-model:visible="state.settingsModalOpened" @close="onSettingsModalClose"
@manual-update-index="updatePageIndex"
:presenting-element="mainRouterOutlet?.$el"></chat-complete-settings-modal>
@manual-update-index="updatePageIndex" :presenting-element="mainRouterOutlet?.$el"></chat-complete-settings-modal>
</ion-page>
</template>
<style lang="scss" scoped>
@import '@/theme/content-container.scss';
@import '@/theme/transition-fade.scss';
#container strong {
font-size: 20px;

@ -5,9 +5,12 @@ export default {
</script>
<script lang="ts" setup>
import { api } from '@/api';
import { usePageStore, useUserStore } from '@/stores';
import { BotPersonaInfo, TypeaheadItem } from '@/types/base';
import { moduleNewConversationRoute } from '@/types/enum';
import { setPageTitle } from '@/utils/other';
import { IonRouterOutletInstance } from '@/types/instance';
import { DEFAULT_BOT_AVATAR, setPageTitle } from '@/utils/other';
import { useIonRouter } from '@ionic/vue';
import { useRoute } from 'vue-router';
@ -17,6 +20,12 @@ const router = useIonRouter()
const userStore = useUserStore()
const pageStore = usePageStore()
const mainRouterOutlet = inject<Ref<IonRouterOutletInstance>>('mainRouterOutlet')
const state = reactive({
showSelectPersonaModal: false,
})
const modules = ['chatcomplete', 'imagen']
const pageTitle = computed(() => {
@ -25,15 +34,57 @@ const pageTitle = computed(() => {
const appName = import.meta.env.VITE_APP_NAME
const newConversation = (moduleName: string) => {
const newConversation = (moduleName: string, params: any = {}) => {
const moduleRouteName = moduleNewConversationRoute[moduleName]
if (moduleRouteName) {
router.replace({
name: moduleRouteName
name: moduleRouteName,
query: params,
})
}
}
// ============================================================================
//
// ============================================================================
let botPersonaList: BotPersonaInfo[] = []
const loadPersonaItems = async (searchQuery: string | undefined): Promise<TypeaheadItem[]> => {
try {
if (!botPersonaList.length) {
let res = await api.chat.getBotPersonaList({})
botPersonaList = res.list
}
return botPersonaList.filter((item) => {
return item.bot_name.includes(searchQuery ?? '')
}).map((item) => {
return {
key: item.id,
title: item.bot_name,
subtitle: item.bot_description,
value: item.bot_id,
icon: item.bot_avatar ?? DEFAULT_BOT_AVATAR,
}
})
} catch (err: any) {
console.error(err)
return []
}
}
const showSelectPersona = () => {
state.showSelectPersonaModal = true
}
const onSelectedPersona = (botId: string) => {
state.showSelectPersonaModal = false
newConversation('chatcomplete', {
botId,
})
}
// ============================================================================
//
// ============================================================================
onMounted(() => {
setPageTitle(pageStore.title)
})
@ -57,7 +108,7 @@ onMounted(() => {
<ion-row>
<ion-col size="12" size-md="6" size-xl="4">
<ion-card href="chatcomplete" :disabled="!userStore.loggedIn || !pageStore.title"
@click.prevent="newConversation('chatcomplete')">
@click.prevent="showSelectPersona()">
<ion-card-header>
<ion-card-title>对话</ion-card-title>
<ion-card-subtitle>
@ -86,6 +137,9 @@ onMounted(() => {
</ion-grid>
</div>
</ion-content>
<!-- 选择机器人 -->
<typeahead-modal v-model:visible="state.showSelectPersonaModal" :presenting-element="mainRouterOutlet?.$el"
title="选择机器人" :load-items="loadPersonaItems" @select="onSelectedPersona" />
</ion-page>
</template>

Loading…
Cancel
Save