分离部分组件

master
落雨楓 1 month ago
parent c7e715be0c
commit b737cd3659

@ -1,5 +1,5 @@
VITE_APP_NAME=异世界工具箱
VITE_TOOLBOX_API_ENDPOINT=/api/toolkit
VITE_TOOLBOX_WS_API_ENDPOINT=/api/toolkit
VITE_TOOLBOX_API_ENDPOINT=/api/toolbox
VITE_TOOLBOX_WS_API_ENDPOINT=/api/toolbox
VITE_TOOLBOX_LOGIN_URL=https://www.isekai.cn/
VITE_DEFAULT_AVATAR=https://account.isekai.cn/realms/isekai/avatar/default.png

@ -12,18 +12,12 @@
"lint": "eslint"
},
"dependencies": {
"@capacitor/android": "4.7.3",
"@capacitor/app": "4.1.1",
"@capacitor/core": "4.7.3",
"@capacitor/haptics": "4.1.0",
"@capacitor/keyboard": "4.1.1",
"@capacitor/status-bar": "4.1.1",
"@ionic/vue": "^7.0.14",
"@ionic/vue-router": "^7.0.14",
"axios": "^1.3.6",
"github-markdown-css": "^5.2.0",
"highlight.js": "^11.7.0",
"ionicons": "^7.1.2",
"ionicons": "^7.4.0",
"js-base64": "^3.7.5",
"lru-cache": "^10.0.0",
"markdown-it": "^13.0.1",
@ -34,7 +28,6 @@
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@capacitor/cli": "4.7.3",
"@types/markdown-it": "^12.2.3",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^11.0.2",

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
import { toolkitApi } from "@/request/toolkit-api";
import { toolboxApi } from "@/request/toolbox-api";
import { ConversationInfo } from "@/types/base";
export type SearchTitleParams = {
@ -35,22 +35,22 @@ export type RemoveConversationParams = {
export const baseApi = {
searchTitle: async (params: SearchTitleParams): Promise<SearchTitleResponse> => {
const { data } = await toolkitApi.get('/title/search', { params })
const { data } = await toolboxApi.get('/title/search', { params })
return data.data.titles
},
getConversationList: async (params: GetConversationListParams = {}): Promise<GetConversationListResponse> => {
const { data } = await toolkitApi.get('/conversation/list', { params })
const { data } = await toolboxApi.get('/conversation/list', { params })
return data.data.conversations
},
getConversationInfo: async (params: GetConversationInfoParams): Promise<ConversationInfo> => {
const { data } = await toolkitApi.get(`/conversation/info`, { params })
const { data } = await toolboxApi.get(`/conversation/info`, { params })
return data.data
},
setConversationTitle: async (params: SetConversationTitleParams): Promise<void> => {
await toolkitApi.post(`/conversation/set_title`, params)
await toolboxApi.post(`/conversation/set_title`, params)
},
setConversationPinned: async (params: SetConversationPinnedParams): Promise<void> => {
await toolkitApi.post(`/conversation/set_pinned`, params)
await toolboxApi.post(`/conversation/set_pinned`, params)
},
removeConversation: async (params: RemoveConversationParams): Promise<void> => {
let apiParams: any = {}
@ -59,6 +59,6 @@ export const baseApi = {
} else if (params.id) {
apiParams.id = params.id
}
await toolkitApi.post(`/conversation/remove`, apiParams)
await toolboxApi.post(`/conversation/remove`, apiParams)
},
}

@ -1,5 +1,5 @@
import { RemoteError } from "@/errors/remoteError"
import { toolkitApi } from "@/request/toolkit-api";
import { toolboxApi } from "@/request/toolbox-api";
import { useUserStore } from "@/stores"
import { BotPersonaInfo, ChatCompleteChunkInfo, ExtractDocInfo } from "@/types/base";
import { showApiError } from "@/utils/error";
@ -125,32 +125,32 @@ export const chatCompleteApi = {
},
async getConversationChunkList(params: GetConversationChunkListParams): Promise<GetConversationChunkListResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/conversation_chunk/list`, { params })
const { data } = await toolboxApi.get(`/chatcomplete/conversation_chunk/list`, { params })
return data.data
},
async getConversationChunkInfo(params: GetConversationChunkInfoParams): Promise<GetConversationChunkInfoResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/conversation_chunk/info`, { params })
const { data } = await toolboxApi.get(`/chatcomplete/conversation_chunk/info`, { params })
return data.data
},
async getEstimatedPointUsage(params: GetEstimatedPointUsageParams): Promise<number> {
const { data } = await toolkitApi.post(`/chatcomplete/get_point_usage`, params)
const { data } = await toolboxApi.post(`/chatcomplete/get_point_usage`, params)
return data.data?.point_usage ?? -1
},
async getBotPersonaList(params: GetBotPersonaListParams): Promise<GetBotPersonaListResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/persona/list`, { params })
const { data } = await toolboxApi.get(`/chatcomplete/persona/list`, { params })
return data.data
},
async getBotPersonaInfo(params: GetBotPersonaInfoParams): Promise<GetBotPersonaInfoResponse> {
const { data } = await toolkitApi.get(`/chatcomplete/persona/info`, { params })
const { data } = await toolboxApi.get(`/chatcomplete/persona/info`, { params })
return data.data
},
async forkConversation(params: ForkConversationParams): Promise<ForkConversationResponse> {
const { data } = await toolkitApi.post(`/chatcomplete/conversation/fork`, params)
const { data } = await toolboxApi.post(`/chatcomplete/conversation/fork`, params)
return data.data
},
@ -180,7 +180,7 @@ export const chatCompleteApi = {
if (args.botId) {
params.bot_id = args.botId
}
const { data } = await toolkitApi.post('/chatcomplete/message', params)
const { data } = await toolboxApi.post('/chatcomplete/message', params)
return data.data
},

@ -1,13 +1,11 @@
<script lang="ts">
export default {
name: 'ChatCompleteSettingsModal'
}
</script>
<script lang="ts" setup>
import { useSettingsStore } from '@/stores'
import { showConfirm } from '@/utils/dialog'
defineOptions({
name: 'ChatCompleteSettingsModal'
})
const props = defineProps<{
visible: boolean,
presentingElement?: HTMLElement,

@ -1,12 +1,10 @@
<script lang="ts">
export default {
name: 'ChatMessage'
}
</script>
<script lang="ts" setup>
import { createOutline, reloadOutline, gitNetworkOutline } from 'ionicons/icons'
defineOptions({
name: 'ChatMessage'
})
const props = defineProps<{
msgId?: string,
sender: string,

@ -2,7 +2,6 @@
import { useConversationStore, usePageStore } from "@/stores"
import { ConversationInfo } from "@/types/base"
import router from "@/router"
import { useRoute } from "vue-router"
import {
add as addIcon,
heartOutline,

@ -3,7 +3,7 @@ import { showError } from '@/utils/dialog'
import { ServerError, showApiError } from '@/utils/error'
import axios, { AxiosResponse } from 'axios'
export const toolkitApi = axios.create({
export const toolboxApi = axios.create({
baseURL: import.meta.env.VITE_TOOLBOX_API_ENDPOINT,
timeout: 30000,
headers: {
@ -11,7 +11,7 @@ export const toolkitApi = axios.create({
},
})
toolkitApi.interceptors.request.use((config) => {
toolboxApi.interceptors.request.use((config) => {
const userStore = useUserStore()
const token = userStore.token
if (token) {
@ -20,7 +20,7 @@ toolkitApi.interceptors.request.use((config) => {
return config
})
toolkitApi.interceptors.response.use((response) => {
toolboxApi.interceptors.response.use((response) => {
if (response.data) {
if (response.data.status !== 0 && response.data.error) {
showError(response.data.error)

@ -1,4 +1,4 @@
import { toolkitApi } from "@/request/toolkit-api"
import { toolboxApi } from "@/request/toolbox-api"
import { showError } from "@/utils/dialog"
import { defineStore } from "pinia"
import { LRUCache } from 'lru-cache'
@ -50,7 +50,7 @@ export const usePageStore = defineStore('page', {
}
// Load from server
const res = await toolkitApi.get('/title/info', {
const res = await toolboxApi.get('/title/info', {
params: {
title: this.title,
}

@ -1,5 +1,5 @@
import { decodeJwt } from "@/utils/jwt"
import { toolkitApi } from "@/request/toolkit-api"
import { toolboxApi } from "@/request/toolbox-api"
import { defineStore } from "pinia"
export type UserState = {
@ -59,7 +59,7 @@ export const useUserStore = defineStore('user', {
this.permissions = ['chatcomplete', 'imagen']
},
async load() {
const res = await toolkitApi.get('/user/info')
const res = await toolboxApi.get('/user/info')
if (res.data.status == 1 && res.data.data) {
const userInfo = res.data.data
this.uid = userInfo.userid

@ -2,19 +2,19 @@ import { useConversationStore } from "@/stores"
import { loadingController } from "@ionic/vue"
export const pinConversation = async (conversationId: number, pinned: boolean) => {
let actionName = pinned ? '收藏' : '取消收藏'
let loading = await loadingController.create({
message: `正在${actionName}...`,
translucent: true,
backdropDismiss: false
})
await loading.present()
try {
const conversationStore = useConversationStore()
await conversationStore.pin(conversationId, pinned)
} catch (err) {
console.error(`${actionName}对话错误`, err)
} finally {
await loading.dismiss()
}
let actionName = pinned ? '收藏' : '取消收藏'
let loading = await loadingController.create({
message: `正在${actionName}...`,
translucent: true,
backdropDismiss: false
})
await loading.present()
try {
const conversationStore = useConversationStore()
await conversationStore.pin(conversationId, pinned)
} catch (err) {
console.error(`${actionName}对话错误`, err)
} finally {
await loading.dismiss()
}
}

@ -1,30 +1,22 @@
<script setup lang="ts">
import { useConversationStore, useGuideStore, usePageStore, useSettingsStore, useUserStore } from '@/stores'
import { createOutline, ellipsisHorizontalCircle, heart, heartOutline, paperPlane, sparkles } from 'ionicons/icons'
import { ellipsisHorizontalCircle, heart, heartOutline, paperPlane, sparkles } from 'ionicons/icons'
import { useRoute } from 'vue-router'
import { api } from '@/api'
import {
IonContent,
loadingController,
toastController,
InfiniteScrollCustomEvent,
useIonRouter
} from '@ionic/vue'
import { showConfirm, showError, showHelpToast } from '@/utils/dialog'
import { ChatCompleteChunkInfo, ChatCompleteMessage } from '@/types/base'
import { DEFAULT_BOT_AVATAR, secondTimestamp, setPageTitle } from '@/utils/other'
import { ChatCompleteChunkInfo } from '@/types/base'
import { setPageTitle } from '@/utils/other'
import { pinConversation } from '@/utils/actions'
import { moduleConversationRoute } from '@/types/enum'
import { IonRouterOutletInstance } from '@/types/instance'
import { IndexPageParams } from '@/api/chatcomplete'
import { ChatCompleteController, ChatCompletePageState, FullTitlePopupState } from './ChatCompleteController'
export type ConversationChunkInfo = {
id: number,
message_data: ChatCompleteChunkInfo,
}
const MODULE_NAME = 'chatcomplete'
const route = useRoute()
const router = useIonRouter()
@ -36,10 +28,10 @@ const settingsStore = useSettingsStore()
const mainRouterOutlet = inject<Ref<IonRouterOutletInstance>>('mainRouterOutlet')
const state = reactive({
const state = reactive<ChatCompletePageState>({
conversationLoading: false,
errorMessage: null as string | null,
chatCompleteError: null as null | string,
errorMessage: null,
chatCompleteError: null,
conversationTitle: '加载中...',
conversationId: 0,
pinned: false,
@ -49,16 +41,16 @@ const state = reactive({
botName: '',
botAvatar: '',
conversationChunkIdList: [] as number[],
messageChunkList: [] as ConversationChunkInfo[],
conversationChunkIdList: [],
messageChunkList: [],
loadedChunkSeek: 0,
lastUserMsgId: [] as [number, string] | [],
lastAssistantMsgId: [] as [number, string] | [],
lastUserMsgId: [],
lastAssistantMsgId: [],
streamingMessage: null as null | string,
streamingMessage: null,
editingId: null as [number, string] | null,
editingId: null,
formMessage: '',
pointUsage: '0',
pointUsageLoading: false,
@ -72,704 +64,30 @@ const contentRef = ref<InstanceType<typeof IonContent> | null>(null)
const conversationInfo = computed(() => conversationStore.list.find((conversation) => conversation.id === state.conversationId))
let editingToast: HTMLIonToastElement | null = null
let autoScrollTimer: NodeJS.Timer | null = null
let ignoreAutoScroll = false
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)
// ============================================================================
//
// ============================================================================
const showPinHelp = () => {
if (!guideStore.helpForPin) {
showHelpToast('未收藏的对话将会在30天后或消息列表满100条时自动删除如果想要永久保留当前对话请点击收藏按钮。')
guideStore.helpForPin = true
}
}
// ============================================================================
//
// ============================================================================
const onMessageInputKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
onSendMessage()
}
}
const onSendMessage = async () => {
if (state.formMessage === '') return
//
let contentEl = contentRef.value?.$el
contentEl?.scrollToBottom()
ignoreAutoScroll = false
state.conversationLoading = true
let latestChunk: ConversationChunkInfo | null = null
let newUserMessage: ChatCompleteMessage | null = null
try {
let question = state.formMessage
let paramEditingId: [number, string] | undefined = undefined
if (state.editingId?.length && state.editingId[1] !== 'error-user-message') {
paramEditingId = state.editingId
}
const startRes = await api.chat.startChatComplete(pageStore.title, question, {
conversationId: state.conversationId,
editMessageId: paramEditingId,
botId: state.botId,
inCollection: settingsStore.collectionMode,
extractLimit: settingsStore.docExtractLimit
})
state.formMessage = ''
await editingToast?.dismiss()
//
if (state.chatCompleteError !== null) {
state.messageChunkList.forEach((chunk) => {
chunk.message_data = chunk.message_data.filter((message) => message.id !== 'error-user-message')
})
state.chatCompleteError = null
}
if (state.messageChunkList.length === 0) {
state.messageChunkList.push({
id: startRes.chunk_id,
message_data: []
})
latestChunk = state.messageChunkList[0]
} else {
if (state.editingId) {
//
let editingChunkIndex = state.messageChunkList.findIndex((chunk) => chunk.id === state.editingId![0])
if (editingChunkIndex >= 0) {
state.messageChunkList = state.messageChunkList.slice(editingChunkIndex)
latestChunk = state.messageChunkList[0]
let editingMessageIndex = latestChunk.message_data.findIndex((message) => message.id === state.editingId![1])
if (editingMessageIndex >= 0) {
latestChunk.message_data = latestChunk.message_data.slice(0, editingMessageIndex)
}
}
}
latestChunk = state.messageChunkList[0] //
if (startRes.chunk_id !== latestChunk.id) {
// chunk
latestChunk = {
id: startRes.chunk_id,
message_data: []
}
state.messageChunkList.unshift(latestChunk)
latestChunk = state.messageChunkList[0] // reactive
}
}
startAutoScroll() //
latestChunk.message_data.push({
role: 'user',
content: question,
time: secondTimestamp(),
tokens: 0
})
let userMessage = latestChunk.message_data[latestChunk.message_data.length - 1]
newUserMessage = userMessage
state.streamingMessage = ''
const chatCompleteRes = await api.chat.chatCompleteStreamOutput(startRes.task_id, (message) => {
state.streamingMessage += message
})
let responseMessage = state.streamingMessage
latestChunk.message_data.push({
role: 'assistant',
id: chatCompleteRes.response_message_id,
content: state.streamingMessage,
time: secondTimestamp(),
tokens: chatCompleteRes.message_tokens,
})
state.streamingMessage = null
userMessage.id = chatCompleteRes.question_message_id
state.lastUserMsgId = [startRes.chunk_id, chatCompleteRes.question_message_id]
state.lastAssistantMsgId = [startRes.chunk_id, chatCompleteRes.response_message_id]
let description = responseMessage.substring(0, 150)
if (description.length < responseMessage.length) {
description += '...'
}
if (startRes.conversation_id !== state.conversationId) {
if (isNewConversaion.value) {
//
conversationStore.list.push({
id: startRes.conversation_id,
module: MODULE_NAME,
title: '未命名对话',
page_title: pageStore.title,
pinned: false,
description: description,
updated_at: secondTimestamp(),
extra: {}
})
setPageTitle('未命名对话')
}
state.conversationId = startRes.conversation_id
let newUrl = route.path.replace(/\/\d+\/?$/, '') + '/' + startRes.conversation_id
history.replaceState({}, '', newUrl)
conversationStore.currentId = startRes.conversation_id
showPinHelp()
} else {
conversationStore.updateById(startRes.conversation_id, {
description,
updated_at: secondTimestamp(),
})
}
if (chatCompleteRes.delta_data.title) {
state.conversationTitle = chatCompleteRes.delta_data.title
conversationStore.updateById(startRes.conversation_id, {
title: chatCompleteRes.delta_data.title
})
setPageTitle(chatCompleteRes.delta_data.title)
}
conversationStore.sort() //
state.conversationLoading = false
setTimeout(() => {
stopAutoScroll()
}, 150)
//
let pointUsage = chatCompleteRes.point_usage
if (typeof pointUsage === 'number') {
if (userStore.points > 0) {
userStore.points = Math.max(0, userStore.points - pointUsage)
} else {
userStore.load()
}
}
userStore.refresh()
} catch (err: any) {
state.conversationLoading = false
state.streamingMessage = null
state.chatCompleteError = err.message
if (latestChunk && newUserMessage) {
if (!newUserMessage.id) {
newUserMessage.id = 'error-user-message'
}
state.lastUserMsgId = [latestChunk.id, newUserMessage.id]
}
}
}
// ============================================================================
//
// ============================================================================
const updatePageIndex = async () => {
//
const loading = await loadingController.create({
message: '正在更新页面索引...',
spinner: 'lines',
translucent: true,
backdropDismiss: false,
keyboardClose: false
})
await loading.present()
try {
let params: IndexPageParams = {
title: pageStore.title,
}
if (settingsStore.collectionMode) {
params.collection = true
params.title = pageStore.collectionTitle
}
await api.chat.indexPage(params, (current, total, currentPage, totalPage) => {
if (totalPage > 1) {
loading.message = `正在更新页面索引... (段落:${current}/${total},页面:${currentPage}/${totalPage})`
} else {
loading.message = `正在更新页面索引... (段落:${current}/${total})`
}
})
} catch (err: any) {
console.error(err)
showError(err.message)
} finally {
await loading.dismiss()
state.conversationLoading = false
}
}
// ============================================================================
//
// ============================================================================
const loadPrevConversationChunk = (): Promise<void> => {
return new Promise(async (resolve, reject) => {
try {
if (state.loadedChunkSeek >= state.conversationChunkIdList.length) {
return
}
let chunkCount = state.conversationChunkIdList.length
let prevChunkId = state.conversationChunkIdList[chunkCount - state.loadedChunkSeek - 1]
let conversationChunk = await api.chat.getConversationChunkInfo({
chunk_id: prevChunkId
})
let contentEl = contentRef.value?.$el
let scrollEl = contentEl?.scrollEl
let oldScrollBottom: number | null = null
if (scrollEl) {
oldScrollBottom = scrollEl.scrollHeight - scrollEl.scrollTop
}
state.messageChunkList.push(conversationChunk)
state.loadedChunkSeek += 1
nextTick(() => requestAnimationFrame(() => {
if (!ignoreAutoScroll) {
contentEl!.scrollToBottom()
} else if (scrollEl && oldScrollBottom !== null) { //
let newScrollTop = scrollEl.scrollHeight - oldScrollBottom
scrollEl.scrollTo({
top: newScrollTop
})
}
resolve()
}))
} catch (err: any) {
state.errorMessage = err.message
resolve()
}
})
}
const loadConversation = async () => {
try {
if (isNewConversaion.value) {
//
state.conversationTitle = '未命名对话'
state.conversationLoading = true
state.botId = route.query.botId as string ?? 'default'
await loadBotPersona()
} else {
state.messageChunkList = []
state.errorMessage = null
state.chatCompleteError = null
state.formMessage = ''
conversationStore.currentId = state.conversationId
//
state.conversationLoading = true
let conversationInfo = await api.base.getConversationInfo({
id: state.conversationId
})
if (conversationInfo.module !== MODULE_NAME) {
showError('当前对话不是聊天对话,请从对话列表重新进入', '提示')
throw new Error('Module mismatch')
}
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
})
if (state.conversationChunkIdList.length > 0) {
let contentEl = contentRef.value?.$el
let scrollEl = contentEl?.scrollEl
await loadPrevConversationChunk()
// chunks
while (state.loadedChunkSeek < state.conversationChunkIdList.length && scrollEl.scrollHeight <= scrollEl.clientHeight) {
await loadPrevConversationChunk()
}
}
// ID
for (let chunk of state.messageChunkList) {
chunk.message_data.forEach((message) => {
if (message.id) {
if (message.role === 'user') {
state.lastUserMsgId = [chunk.id, message.id]
} else {
state.lastAssistantMsgId = [chunk.id, message.id]
}
}
})
if (state.lastUserMsgId.length > 0 && state.lastAssistantMsgId.length > 0) {
break
}
}
}
} catch (err: any) {
state.errorMessage = err.message
} finally {
state.conversationLoading = false
}
}
const onInfiniteScrollLoad = async (event: InfiniteScrollCustomEvent) => {
console.log('load by infinite scroll')
await loadPrevConversationChunk()
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)
}
}
// ============================================================================
//
// ============================================================================
const onEditMessage = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法编辑这个提问,请等待当前生成完成', '提示')
} else {
let chunk = state.messageChunkList.find((chunk) => chunk.id === chunkId)
if (chunk) {
let message = chunk.message_data.find((message) => message.id === messageId)
if (message) {
state.editingId = [chunkId, messageId]
state.formMessage = message.content
editingToast = await toastController.create({
message: '正在编辑历史消息',
position: 'middle',
icon: createOutline,
color: 'medium',
buttons: [
{
text: '取消',
role: 'cancel',
handler: async () => {
state.editingId = null
state.formMessage = ''
await editingToast?.dismiss()
}
},
]
})
await editingToast.present()
return
}
}
showError('需要编辑的提问不存在', '提示')
}
}
const onChangeResponse = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法重新生成这个提问,请等待当前生成完成', '提示')
} else {
if (await showConfirm('重新生成回复将会覆盖原有的内容,且会再次消耗积分,你要继续吗?')) {
let chunk = state.messageChunkList.find((chunk) => chunk.id === chunkId)
if (chunk) {
let messagePos = chunk.message_data.findIndex((message) => message.id === messageId)
if (messagePos > 0) {
let questionMsg = chunk.message_data[messagePos - 1]
if (questionMsg.id) {
state.editingId = [chunkId, questionMsg.id]
state.formMessage = questionMsg.content
onSendMessage()
return
}
}
}
showError('需要重新生成的提问不存在', '提示')
}
}
}
const onChangeLastResponse = async () => {
let lastUserMessageIdx = state.messageChunkList[0]?.message_data
.findLastIndex((message) => message.role === 'user')
if (lastUserMessageIdx === undefined || lastUserMessageIdx < 0) {
showError('未找到先前的提问,请刷新页面重试', '提示')
} else {
if (await showConfirm('你确定要重新生成这段对话吗?')) {
let lastUserMessage = state.messageChunkList[0].message_data[lastUserMessageIdx]
//
state.messageChunkList[0].message_data.splice(lastUserMessageIdx, 1)
state.formMessage = lastUserMessage.content
onSendMessage()
}
}
}
const onCreateBranch = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法在这个回答上创建分支,请等待当前生成完成', '提示')
} else {
if (await showConfirm('创建分支后不会复制全部的对话记录,但是机器人的记忆会完整复制。你要继续吗?')) {
let loading = await loadingController.create({
message: '正在创建分支...',
spinner: 'lines',
translucent: true,
backdropDismiss: false,
keyboardClose: false
})
await loading.present()
try {
//
let newTitle = `分支:${state.conversationTitle}`
for (var i = 1; i <= 99; i++) {
newTitle = `分支${i.toString().padStart(2, '0')}${state.conversationTitle}`
if (!conversationStore.list.find((conversation) => conversation.title === newTitle)) {
break
}
}
let res = await api.chat.forkConversation({
id: state.conversationId,
message_id: `${chunkId},${messageId}`,
new_title: newTitle
})
await conversationStore.load()
router.replace({
name: moduleConversationRoute[MODULE_NAME],
params: {
id: res.conversation_id
}
})
} catch (err: any) {
console.error(err)
} finally {
await loading.dismiss()
}
}
}
}
// ============================================================================
//
// ============================================================================
const startAutoScroll = () => {
if (autoScrollTimer) {
clearInterval(autoScrollTimer)
}
if (contentRef.value) {
let contentEl = contentRef.value.$el
let scrollEl = contentEl.scrollEl
autoScrollTimer = setInterval(() => {
if (!ignoreAutoScroll) {
contentEl.scrollToBottom()
}
}, 100)
}
}
const stopAutoScroll = () => {
if (autoScrollTimer) {
clearInterval(autoScrollTimer)
autoScrollTimer = null
}
}
//
const updateAutoScrollEnabled = () => {
if (contentRef.value) {
let contentEl = contentRef.value.$el
let scrollEl = contentEl.scrollEl
if (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 30) {
ignoreAutoScroll = false
} else {
ignoreAutoScroll = true
}
console.log('ignoreAutoScroll', ignoreAutoScroll)
}
}
let isUserScroll = false
let scrollDebounceTimer: any = null
const onMainContentWheel = (ev: WheelEvent) => {
isUserScroll = true
}
const onMainContentTouchStart = (ev: TouchEvent) => {
isUserScroll = true
}
const onMainContentTouchEnd = (ev: TouchEvent) => {
scrollDebounceTimer = setTimeout(() => {
updateAutoScrollEnabled()
isUserScroll = false
}, 100)
}
const onMainContentScroll = (ev: Event) => {
if (!isUserScroll) return
ignoreAutoScroll = true
if (scrollDebounceTimer) {
clearTimeout(scrollDebounceTimer)
}
scrollDebounceTimer = setTimeout(() => {
updateAutoScrollEnabled()
isUserScroll = false
}, 100)
}
// ============================================================================
//
// ============================================================================
const updatePointUsage = async () => {
if (userStore.unlimitedUsage) {
state.pointUsage = '0'
state.pointUsageLoading = false
return
}
if (state.formMessage === '') {
state.pointUsage = '0'
state.pointUsageLoading = false
return
}
try {
const pointUsage = await api.chat.getEstimatedPointUsage({
bot_id: state.botId,
question: state.formMessage,
})
if (pointUsage === -1) {
state.pointUsage = '???'
} else {
state.pointUsage = pointUsage.toString()
}
} catch (err) {
console.error(err)
state.pointUsage = '???'
}
state.pointUsageLoading = false
}
let inComposition = false
let formMessageInputThrottleTimer: any = null
const onInput = (event: any) => {
state.pointUsageLoading = true
if (event.isComposing || inComposition) {
return
}
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
formMessageInputThrottleTimer = setTimeout(() => {
updatePointUsage()
}, 300)
}
const onCompositionStart = () => {
inComposition = true
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
}
const onCompositionEnd = () => {
inComposition = false
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
formMessageInputThrottleTimer = setTimeout(() => {
updatePointUsage()
}, 400)
}
// ============================================================================
//
// ============================================================================
const fullTitlePopupState = reactive({
const fullTitlePopupState = reactive<FullTitlePopupState>({
isOpen: false,
event: null as any
event: null
})
const showFullTitlePopup = (ev: any) => {
fullTitlePopupState.isOpen = true
fullTitlePopupState.event = ev
}
// ============================================================================
//
// ============================================================================
let oldSettings: any = {}
const openSettingsModal = () => {
state.settingsModalOpened = true
oldSettings = Object.assign({}, settingsStore)
}
const onSettingsModalClose = () => {
state.settingsModalOpened = false
if (settingsStore.collectionMode !== oldSettings.collectionMode) {
updatePageIndex()
}
updatePointUsage()
}
const controller = new ChatCompleteController(
state,
contentRef,
isNewConversaion,
fullTitlePopupState,
route,
router,
userStore,
pageStore,
conversationStore,
guideStore,
settingsStore,
);
// ============================================================================
//
@ -777,33 +95,20 @@ const onSettingsModalClose = () => {
let mainContentResizeObserver: ResizeObserver | null = null
onMounted(async () => {
await updatePageIndex()
await loadConversation()
await controller.updatePageIndex()
await controller.loadConversation()
setPageTitle(state.conversationTitle, '写作助手')
nextTick(() => {
if (contentRef.value) {
let contentEl = contentRef.value.$el;
(<any>window)._contentEl = contentEl
contentEl.scrollEl.addEventListener('scroll', onMainContentScroll, { passive: true })
mainContentResizeObserver = new ResizeObserver(() => {
if (!ignoreAutoScroll) {
contentEl.scrollEl.scrollTo({
top: contentEl.scrollEl.scrollHeight,
})
}
})
mainContentResizeObserver.observe(contentEl.scrollEl)
}
})
})
onBeforeUnmount(() => {
if (contentRef.value) {
let contentEl = contentRef.value.$el
contentEl.scrollEl?.removeEventListener('scroll', onMainContentScroll)
contentEl.scrollEl?.removeEventListener('scroll', controller.onMainContentScroll)
if (mainContentResizeObserver) {
mainContentResizeObserver.disconnect()
mainContentResizeObserver = null
@ -832,7 +137,7 @@ watch(() => conversationInfo.value, (currentConversation) => {
<ion-buttons slot="start">
<ion-menu-button color="primary"></ion-menu-button>
</ion-buttons>
<ion-title @click="showFullTitlePopup">
<ion-title @click="controller.showFullTitlePopup">
<loading-title :loading="state.conversationLoading" class="text-selectable">
{{ state.conversationTitle || '无标题' }}
</loading-title>
@ -855,33 +160,32 @@ watch(() => conversationInfo.value, (currentConversation) => {
</ion-popover>
</ion-header>
<ion-content :fullscreen="true" ref="contentRef" @wheel.passive="onMainContentWheel"
@touchstart.passive="onMainContentTouchStart" @touchend.passive="onMainContentTouchEnd">
<ion-content :fullscreen="true" ref="contentRef" @wheel.passive="controller.onMainContentWheel"
@touchstart.passive="controller.onMainContentTouchStart" @touchend.passive="controller.onMainContentTouchEnd">
<ion-infinite-scroll position="top" v-if="state.loadedChunkSeek < state.conversationChunkIdList.length"
@ion-infinite="onInfiniteScrollLoad">
@ion-infinite="controller.onInfiniteScrollLoad">
<ion-infinite-scroll-content loadingText="加载中..." loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
<!-- 消息列表 -->
<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"
<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>
@click-edit="controller.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>
@click-create-branch="controller.onCreateBranch(chunk.id, message.id)"
@click-change="controller.onChangeResponse(chunk.id, message.id)"></chat-message>
</template>
</div>
<!-- 流式输出消息 -->
<div v-if="state.streamingMessage !== null || state.chatCompleteError !== null" class="message-chunk">
<chat-message :sender="state.botName" :avatar="state.botAvatar"
:content="state.streamingMessage ?? undefined" :error="state.chatCompleteError ?? undefined" assistant cursor
:change="state.chatCompleteError !== null"
@click-change="onChangeLastResponse()"></chat-message>
<chat-message :sender="state.botName" :avatar="state.botAvatar" :content="state.streamingMessage ?? undefined"
:error="state.chatCompleteError ?? undefined" assistant cursor :change="state.chatCompleteError !== null"
@click-change="controller.onChangeLastResponse()"></chat-message>
</div>
</transition-group>
</ion-content>
@ -890,7 +194,7 @@ watch(() => conversationInfo.value, (currentConversation) => {
<ion-toolbar class="message-input-toolbar">
<div class="container message-input-container">
<ion-buttons class="message-action-btn-container">
<ion-button class="message-action-btn" title="选项" @click="openSettingsModal">
<ion-button class="message-action-btn" title="选项" @click="controller.openSettingsModal">
<ion-icon :icon="ellipsisHorizontalCircle" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
@ -899,16 +203,16 @@ watch(() => conversationInfo.value, (currentConversation) => {
<ion-label color="medium" v-show="!state.pointUsageLoading">{{ state.pointUsage }}</ion-label>
<ion-spinner v-show="state.pointUsageLoading" color="medium" name="dots"></ion-spinner>
</ion-chip>
<ion-textarea class="message-input" aria-label="" :rows="1" :maxlength="768" placeholder="输入问题"
auto-grow v-model="state.formMessage" @keydown="onMessageInputKeyDown" @ion-input="onInput"
@compositionstart="onCompositionStart" @compositionend="onCompositionEnd"
<ion-textarea class="message-input" aria-label="" :rows="1" :maxlength="768" placeholder="输入问题" auto-grow
v-model="state.formMessage" @keydown="controller.onMessageInputKeyDown" @ion-input="controller.onInput"
@compositionstart="controller.onCompositionStart" @compositionend="controller.onCompositionEnd"
:disabled="state.conversationLoading"></ion-textarea>
<ion-button shape="round" class="message-send-btn" title="发送" @click="onSendMessage"
<ion-button shape="round" class="message-send-btn" title="发送" @click="controller.onSendMessage"
:disabled="state.conversationLoading || state.formMessage.length === 0 || state.pointUsageLoading">
<ion-icon :icon="paperPlane" slot="start"></ion-icon>
发送
</ion-button>
<ion-button fill="clear" class="message-send-btn-mobile" title="发送" @click="onSendMessage"
<ion-button fill="clear" class="message-send-btn-mobile" title="发送" @click="controller.onSendMessage"
:disabled="state.conversationLoading || state.formMessage.length === 0 || state.pointUsageLoading">
<ion-icon :icon="paperPlane" slot="icon-only" size="default"></ion-icon>
</ion-button>
@ -916,8 +220,9 @@ watch(() => conversationInfo.value, (currentConversation) => {
</ion-toolbar>
</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>
<chat-complete-settings-modal v-model:visible="state.settingsModalOpened" @close="controller.onSettingsModalClose"
@manual-update-index="controller.updatePageIndex"
:presenting-element="mainRouterOutlet?.$el"></chat-complete-settings-modal>
</ion-page>
</template>

@ -0,0 +1,754 @@
import { useConversationStore, useGuideStore, usePageStore, useSettingsStore, useUserStore } from "@/stores"
import { InfiniteScrollCustomEvent, IonContent, loadingController, toastController, UseIonRouterResult } from "@ionic/vue"
import { RouteLocationNormalizedLoaded } from "vue-router"
import { Ref } from "vue"
import { ConversationChunkInfo } from "./ChatComplete.vue"
import { ChatCompleteMessage } from "@/types/base"
import { api } from "@/api"
import { DEFAULT_BOT_AVATAR, secondTimestamp, setPageTitle } from "@/utils/other"
import { IndexPageParams } from "@/api/chatcomplete"
import { createOutline } from "ionicons/icons"
import { showConfirm, showError, showHelpToast } from "@/utils/dialog"
import { moduleConversationRoute } from "@/types/enum"
const MODULE_NAME = 'chatcomplete'
export type ChatCompletePageState = {
conversationLoading: boolean,
errorMessage: string | null,
chatCompleteError: string | null,
conversationTitle: string,
conversationId: number,
pinned: boolean,
personaLoaded: boolean,
botId: string,
botName: string,
botAvatar: string,
conversationChunkIdList: number[],
messageChunkList: ConversationChunkInfo[],
loadedChunkSeek: number,
lastUserMsgId: [number, string] | [],
lastAssistantMsgId: [number, string] | [],
streamingMessage: string | null,
editingId: [number, string] | null,
formMessage: string,
pointUsage: string,
pointUsageLoading: boolean,
settingsModalOpened: boolean
}
export type FullTitlePopupState = {
isOpen: boolean,
event: any
}
export class ChatCompleteController {
public editingToast: HTMLIonToastElement | null = null
public autoScrollTimer: NodeJS.Timer | null = null
public ignoreAutoScroll = false
public isUserScroll = false
public scrollDebounceTimer: any = null
public inComposition = false
public formMessageInputThrottleTimer: any = null
public oldSettings: any = {}
constructor(
private state: ChatCompletePageState,
private contentRef: Ref<InstanceType<typeof IonContent> | null>,
private isNewConversaion: Ref<boolean>,
private fullTitlePopupState: FullTitlePopupState,
private route: RouteLocationNormalizedLoaded,
private router: UseIonRouterResult,
private userStore: ReturnType<typeof useUserStore>,
private pageStore: ReturnType<typeof usePageStore>,
private conversationStore: ReturnType<typeof useConversationStore>,
private guideStore: ReturnType<typeof useGuideStore>,
private settingsStore: ReturnType<typeof useSettingsStore>,
) { }
// ============================================================================
// 帮助信息
// ============================================================================
public showPinHelp = () => {
if (!this.guideStore.helpForPin) {
showHelpToast('未收藏的对话将会在30天后或消息列表满100条时自动删除如果想要永久保留当前对话请点击收藏按钮。')
this.guideStore.helpForPin = true
}
}
// ============================================================================
// 发送消息
// ============================================================================
public onMessageInputKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
this.onSendMessage()
}
}
public onSendMessage = async () => {
if (this.state.formMessage === '') return
// 滚动到底部,并启动自动滚动
let contentEl = this.contentRef.value?.$el
contentEl?.scrollToBottom()
this.ignoreAutoScroll = false
this.state.conversationLoading = true
let latestChunk: ConversationChunkInfo | null = null
let newUserMessage: ChatCompleteMessage | null = null
try {
let question = this.state.formMessage
let paramEditingId: [number, string] | undefined = undefined
if (this.state.editingId?.length && this.state.editingId[1] !== 'error-user-message') {
paramEditingId = this.state.editingId
}
const startRes = await api.chat.startChatComplete(this.pageStore.title, question, {
conversationId: this.state.conversationId,
editMessageId: paramEditingId,
botId: this.state.botId,
inCollection: this.settingsStore.collectionMode,
extractLimit: this.settingsStore.docExtractLimit
})
this.state.formMessage = ''
await this.editingToast?.dismiss()
// 删除所有遇到错误的消息
if (this.state.chatCompleteError !== null) {
this.state.messageChunkList.forEach((chunk) => {
chunk.message_data = chunk.message_data.filter((message) => message.id !== 'error-user-message')
})
this.state.chatCompleteError = null
}
if (this.state.messageChunkList.length === 0) {
this.state.messageChunkList.push({
id: startRes.chunk_id,
message_data: []
})
latestChunk = this.state.messageChunkList[0]
} else {
if (this.state.editingId) {
// 删除编辑的消息之前的消息
let editingChunkIndex = this.state.messageChunkList.findIndex((chunk) => chunk.id === this.state.editingId![0])
if (editingChunkIndex >= 0) {
this.state.messageChunkList = this.state.messageChunkList.slice(editingChunkIndex)
latestChunk = this.state.messageChunkList[0]
let editingMessageIndex = latestChunk.message_data.findIndex((message) => message.id === this.state.editingId![1])
if (editingMessageIndex >= 0) {
latestChunk.message_data = latestChunk.message_data.slice(0, editingMessageIndex)
}
}
}
latestChunk = this.state.messageChunkList[0] // 反向列表,最新对话在最前面
if (startRes.chunk_id !== latestChunk.id) {
// 新建chunk
latestChunk = {
id: startRes.chunk_id,
message_data: []
}
this.state.messageChunkList.unshift(latestChunk)
latestChunk = this.state.messageChunkList[0] // 获取reactive对象
}
}
this.startAutoScroll() // 开始自动滚动
latestChunk.message_data.push({
role: 'user',
content: question,
time: secondTimestamp(),
tokens: 0
})
let userMessage = latestChunk.message_data[latestChunk.message_data.length - 1]
newUserMessage = userMessage
this.state.streamingMessage = ''
const chatCompleteRes = await api.chat.chatCompleteStreamOutput(startRes.task_id, (message) => {
this.state.streamingMessage += message
})
let responseMessage = this.state.streamingMessage
latestChunk.message_data.push({
role: 'assistant',
id: chatCompleteRes.response_message_id,
content: this.state.streamingMessage,
time: secondTimestamp(),
tokens: chatCompleteRes.message_tokens,
})
this.state.streamingMessage = null
userMessage.id = chatCompleteRes.question_message_id
this.state.lastUserMsgId = [startRes.chunk_id, chatCompleteRes.question_message_id]
this.state.lastAssistantMsgId = [startRes.chunk_id, chatCompleteRes.response_message_id]
let description = responseMessage.substring(0, 150)
if (description.length < responseMessage.length) {
description += '...'
}
if (startRes.conversation_id !== this.state.conversationId) {
if (this.isNewConversaion.value) {
// 新建对话
this.conversationStore.list.push({
id: startRes.conversation_id,
module: MODULE_NAME,
title: '未命名对话',
page_title: this.pageStore.title,
pinned: false,
description: description,
updated_at: secondTimestamp(),
extra: {}
})
setPageTitle('未命名对话')
}
this.state.conversationId = startRes.conversation_id
let newUrl = this.route.path.replace(/\/\d+\/?$/, '') + '/' + startRes.conversation_id
history.replaceState({}, '', newUrl)
this.conversationStore.currentId = startRes.conversation_id
this.showPinHelp()
} else {
this.conversationStore.updateById(startRes.conversation_id, {
description,
updated_at: secondTimestamp(),
})
}
if (chatCompleteRes.delta_data.title) {
this.state.conversationTitle = chatCompleteRes.delta_data.title
this.conversationStore.updateById(startRes.conversation_id, {
title: chatCompleteRes.delta_data.title
})
setPageTitle(chatCompleteRes.delta_data.title)
}
this.conversationStore.sort() // 排序对话列表
this.state.conversationLoading = false
setTimeout(() => {
this.stopAutoScroll()
}, 150)
// 更新用户积分
let pointUsage = chatCompleteRes.point_usage
if (typeof pointUsage === 'number') {
if (this.userStore.points > 0) {
this.userStore.points = Math.max(0, this.userStore.points - pointUsage)
} else {
this.userStore.load()
}
}
this.userStore.refresh()
} catch (err: any) {
this.state.conversationLoading = false
this.state.streamingMessage = null
this.state.chatCompleteError = err.message
if (latestChunk && newUserMessage) {
if (!newUserMessage.id) {
newUserMessage.id = 'error-user-message'
}
this.state.lastUserMsgId = [latestChunk.id, newUserMessage.id]
}
}
}
// ============================================================================
// 更新索引
// ============================================================================
public updatePageIndex = async () => {
// 索引页面
const loading = await loadingController.create({
message: '正在更新页面索引...',
spinner: 'lines',
translucent: true,
backdropDismiss: false,
keyboardClose: false
})
await loading.present()
try {
let params: IndexPageParams = {
title: this.pageStore.title,
}
if (this.settingsStore.collectionMode) {
params.collection = true
params.title = this.pageStore.collectionTitle
}
await api.chat.indexPage(params, (current, total, currentPage, totalPage) => {
if (totalPage > 1) {
loading.message = `正在更新页面索引... (段落:${current}/${total},页面:${currentPage}/${totalPage})`
} else {
loading.message = `正在更新页面索引... (段落:${current}/${total})`
}
})
} catch (err: any) {
console.error(err)
showError(err.message)
} finally {
await loading.dismiss()
this.state.conversationLoading = false
}
}
// ============================================================================
// 加载消息
// ============================================================================
public loadPrevConversationChunk = (): Promise<void> => {
return new Promise(async (resolve, reject) => {
try {
if (this.state.loadedChunkSeek >= this.state.conversationChunkIdList.length) {
return
}
let chunkCount = this.state.conversationChunkIdList.length
let prevChunkId = this.state.conversationChunkIdList[chunkCount - this.state.loadedChunkSeek - 1]
let conversationChunk = await api.chat.getConversationChunkInfo({
chunk_id: prevChunkId
})
let contentEl = this.contentRef.value?.$el
let scrollEl = contentEl?.scrollEl
let oldScrollBottom: number | null = null
if (scrollEl) {
oldScrollBottom = scrollEl.scrollHeight - scrollEl.scrollTop
}
this.state.messageChunkList.push(conversationChunk)
this.state.loadedChunkSeek += 1
nextTick(() => requestAnimationFrame(() => {
if (!this.ignoreAutoScroll) {
contentEl!.scrollToBottom()
} else if (scrollEl && oldScrollBottom !== null) { // 滚动到旧的位置
let newScrollTop = scrollEl.scrollHeight - oldScrollBottom
scrollEl.scrollTo({
top: newScrollTop
})
}
resolve()
}))
} catch (err: any) {
this.state.errorMessage = err.message
resolve()
}
})
}
public loadConversation = async () => {
try {
if (this.isNewConversaion.value) {
// 新建对话
this.state.conversationTitle = '未命名对话'
this.state.conversationLoading = true
this.state.botId = this.route.query.botId as string ?? 'default'
await this.loadBotPersona()
} else {
this.state.messageChunkList = []
this.state.errorMessage = null
this.state.chatCompleteError = null
this.state.formMessage = ''
this.conversationStore.currentId = this.state.conversationId
// 加载对话信息
this.state.conversationLoading = true
let conversationInfo = await api.base.getConversationInfo({
id: this.state.conversationId
})
if (conversationInfo.module !== MODULE_NAME) {
showError('当前对话不是聊天对话,请从对话列表重新进入', '提示')
throw new Error('Module mismatch')
}
this.state.conversationTitle = conversationInfo.title ?? '未命名对话'
this.state.pinned = conversationInfo.pinned
this.state.botId = conversationInfo.extra.bot_id || 'default'
await this.loadBotPersona()
this.state.conversationChunkIdList = await api.chat.getConversationChunkList({
id: this.state.conversationId
})
if (this.state.conversationChunkIdList.length > 0) {
let contentEl = this.contentRef.value?.$el
let scrollEl = contentEl?.scrollEl
await this.loadPrevConversationChunk()
// 加载全屏chunks
while (this.state.loadedChunkSeek < this.state.conversationChunkIdList.length && scrollEl.scrollHeight <= scrollEl.clientHeight) {
await this.loadPrevConversationChunk()
}
}
// 设置最新消息ID
for (let chunk of this.state.messageChunkList) {
chunk.message_data.forEach((message) => {
if (message.id) {
if (message.role === 'user') {
this.state.lastUserMsgId = [chunk.id, message.id]
} else {
this.state.lastAssistantMsgId = [chunk.id, message.id]
}
}
})
if (this.state.lastUserMsgId.length > 0 && this.state.lastAssistantMsgId.length > 0) {
break
}
}
}
} catch (err: any) {
this.state.errorMessage = err.message
} finally {
this.state.conversationLoading = false
}
}
public onInfiniteScrollLoad = async (event: InfiniteScrollCustomEvent) => {
console.log('load by infinite scroll')
await this.loadPrevConversationChunk()
await event.target.complete()
}
// ============================================================================
// 加载机器人信息
// ============================================================================
public loadBotPersona = async () => {
if (this.state.personaLoaded) return
this.state.personaLoaded = true
try {
let personaInfo = await api.chat.getBotPersonaInfo({
bot_id: this.state.botId
})
if (this.isNewConversaion.value) {
// 新对话,加载机器人预设值
this.state.messageChunkList.push({
id: 0,
message_data: personaInfo.message_log?.map((message, index) => ({
...message,
id: '',
time: secondTimestamp()
})) ?? []
})
this.state.formMessage = personaInfo.default_question ?? ''
}
this.state.botName = personaInfo.bot_name
this.state.botAvatar = personaInfo.bot_avatar || DEFAULT_BOT_AVATAR
} catch (err: any) {
console.error(err)
}
}
// ============================================================================
// 修改消息、创建分支
// ============================================================================
public onEditMessage = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法编辑这个提问,请等待当前生成完成', '提示')
} else {
let chunk = this.state.messageChunkList.find((chunk) => chunk.id === chunkId)
if (chunk) {
let message = chunk.message_data.find((message) => message.id === messageId)
if (message) {
this.state.editingId = [chunkId, messageId]
this.state.formMessage = message.content
this.editingToast = await toastController.create({
message: '正在编辑历史消息',
position: 'middle',
icon: createOutline,
color: 'medium',
buttons: [
{
text: '取消',
role: 'cancel',
handler: async () => {
this.state.editingId = null
this.state.formMessage = ''
await this.editingToast?.dismiss()
}
},
]
})
await this.editingToast.present()
return
}
}
showError('需要编辑的提问不存在', '提示')
}
}
public onChangeResponse = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法重新生成这个提问,请等待当前生成完成', '提示')
} else {
if (await showConfirm('重新生成回复将会覆盖原有的内容,且会再次消耗积分,你要继续吗?')) {
let chunk = this.state.messageChunkList.find((chunk) => chunk.id === chunkId)
if (chunk) {
let messagePos = chunk.message_data.findIndex((message) => message.id === messageId)
if (messagePos > 0) {
let questionMsg = chunk.message_data[messagePos - 1]
if (questionMsg.id) {
this.state.editingId = [chunkId, questionMsg.id]
this.state.formMessage = questionMsg.content
this.onSendMessage()
return
}
}
}
showError('需要重新生成的提问不存在', '提示')
}
}
}
public onChangeLastResponse = async () => {
let lastUserMessageIdx = this.state.messageChunkList[0]?.message_data
.findLastIndex((message) => message.role === 'user')
if (lastUserMessageIdx === undefined || lastUserMessageIdx < 0) {
showError('未找到先前的提问,请刷新页面重试', '提示')
} else {
if (await showConfirm('你确定要重新生成这段对话吗?')) {
let lastUserMessage = this.state.messageChunkList[0].message_data[lastUserMessageIdx]
// 删除最后一个用户提问
this.state.messageChunkList[0].message_data.splice(lastUserMessageIdx, 1)
this.state.formMessage = lastUserMessage.content
this.onSendMessage()
}
}
}
public onCreateBranch = async (chunkId: number, messageId?: string) => {
if (!messageId) {
showError('无法在这个回答上创建分支,请等待当前生成完成', '提示')
} else {
if (await showConfirm('创建分支后不会复制全部的对话记录,但是机器人的记忆会完整复制。你要继续吗?')) {
let loading = await loadingController.create({
message: '正在创建分支...',
spinner: 'lines',
translucent: true,
backdropDismiss: false,
keyboardClose: false
})
await loading.present()
try {
// 生成新分支的标题
let newTitle = `分支:${this.state.conversationTitle}`
for (var i = 1; i <= 99; i++) {
newTitle = `分支${i.toString().padStart(2, '0')}${this.state.conversationTitle}`
if (!this.conversationStore.list.find((conversation) => conversation.title === newTitle)) {
break
}
}
let res = await api.chat.forkConversation({
id: this.state.conversationId,
message_id: `${chunkId},${messageId}`,
new_title: newTitle
})
await this.conversationStore.load()
this.router.replace({
name: moduleConversationRoute[MODULE_NAME],
params: {
id: res.conversation_id
}
})
} catch (err: any) {
console.error(err)
} finally {
await loading.dismiss()
}
}
}
}
// ============================================================================
// 自动滚动相关
// ============================================================================
public startAutoScroll = () => {
if (this.autoScrollTimer) {
clearInterval(this.autoScrollTimer)
}
if (this.contentRef.value) {
let contentEl = this.contentRef.value.$el
let scrollEl = contentEl.scrollEl
this.autoScrollTimer = setInterval(() => {
if (!this.ignoreAutoScroll) {
contentEl.scrollToBottom()
}
}, 100)
}
}
public stopAutoScroll = () => {
if (this.autoScrollTimer) {
clearInterval(this.autoScrollTimer)
this.autoScrollTimer = null
}
}
// 用户滚动时禁用自动滚动
public updateAutoScrollEnabled = () => {
if (this.contentRef.value) {
let contentEl = this.contentRef.value.$el
let scrollEl = contentEl.scrollEl
if (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 30) {
this.ignoreAutoScroll = false
} else {
this.ignoreAutoScroll = true
}
console.log('ignoreAutoScroll', this.ignoreAutoScroll)
}
}
public onMainContentWheel = (ev: WheelEvent) => {
this.isUserScroll = true
}
public onMainContentTouchStart = (ev: TouchEvent) => {
this.isUserScroll = true
}
public onMainContentTouchEnd = (ev: TouchEvent) => {
this.scrollDebounceTimer = setTimeout(() => {
this.updateAutoScrollEnabled()
this.isUserScroll = false
}, 100)
}
public onMainContentScroll = (ev: Event) => {
if (!this.isUserScroll) return
this.ignoreAutoScroll = true
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer)
}
this.scrollDebounceTimer = setTimeout(() => {
this.updateAutoScrollEnabled()
this.isUserScroll = false
}, 100)
}
// ============================================================================
// 计算积分
// ============================================================================
public updatePointUsage = async () => {
if (this.userStore.unlimitedUsage) {
this.state.pointUsage = '0'
this.state.pointUsageLoading = false
return
}
if (this.state.formMessage === '') {
this.state.pointUsage = '0'
this.state.pointUsageLoading = false
return
}
try {
const pointUsage = await api.chat.getEstimatedPointUsage({
bot_id: this.state.botId,
question: this.state.formMessage,
})
if (pointUsage === -1) {
this.state.pointUsage = '???'
} else {
this.state.pointUsage = pointUsage.toString()
}
} catch (err) {
console.error(err)
this.state.pointUsage = '???'
}
this.state.pointUsageLoading = false
}
public onInput = (event: any) => {
this.state.pointUsageLoading = true
if (event.isComposing || this.inComposition) {
return
}
if (this.formMessageInputThrottleTimer) {
clearTimeout(this.formMessageInputThrottleTimer)
}
this.formMessageInputThrottleTimer = setTimeout(() => {
this.updatePointUsage()
}, 300)
}
public onCompositionStart = () => {
this.inComposition = true
if (this.formMessageInputThrottleTimer) {
clearTimeout(this.formMessageInputThrottleTimer)
}
}
public onCompositionEnd = () => {
this.inComposition = false
if (this.formMessageInputThrottleTimer) {
clearTimeout(this.formMessageInputThrottleTimer)
}
this.formMessageInputThrottleTimer = setTimeout(() => {
this.updatePointUsage()
}, 400)
}
// ============================================================================
// 点击标题显示完整标题
// ============================================================================
public showFullTitlePopup = (ev: any) => {
this.fullTitlePopupState.isOpen = true
this.fullTitlePopupState.event = ev
}
// ============================================================================
// 设置
// ============================================================================
public openSettingsModal = () => {
this.state.settingsModalOpened = true
this.oldSettings = Object.assign({}, this.settingsStore)
}
public onSettingsModalClose = () => {
this.state.settingsModalOpened = false
if (this.settingsStore.collectionMode !== this.oldSettings.collectionMode) {
this.updatePageIndex()
}
this.updatePointUsage()
}
}

@ -47,11 +47,11 @@ export default defineConfig({
port: env.VITE_PORT as unknown as number || 5174,
open: env.VITE_OPEN,
proxy: {
'/api/toolkit': {
target: 'http://127.0.0.1:8144/',
'/api/toolbox': {
target: 'http://192.168.0.15:8144/',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/toolkit/, ''),
rewrite: (path) => path.replace(/^\/api\/toolbox/, ''),
},
},
}

Loading…
Cancel
Save