完成对话功能
parent
e8995fc652
commit
5ab7be5adc
@ -1,2 +1,4 @@
|
||||
VITE_TOOLKIT_API_ENDPOINT=/api
|
||||
VITE_APP_NAME=异世界工具箱
|
||||
VITE_TOOLKIT_API_ENDPOINT=/api/toolkit
|
||||
VITE_TOOLKIT_WS_API_ENDPOINT=/api/toolkit
|
||||
VITE_DEFAULT_AVATAR=https://account.isekai.cn/realms/isekai/avatar/default.png
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
Allow: /$
|
||||
Allow: /toolbox/$
|
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
@ -0,0 +1,64 @@
|
||||
import { toolkitApi } from "@/request/toolkit-api";
|
||||
import { ConversationInfo } from "@/types/base";
|
||||
|
||||
export type SearchTitleParams = {
|
||||
kw: string
|
||||
}
|
||||
|
||||
export type SearchTitleResponse = string[]
|
||||
|
||||
export type GetConversationListParams = {
|
||||
title?: string,
|
||||
module?: string
|
||||
}
|
||||
|
||||
export type GetConversationListResponse = ConversationInfo[]
|
||||
|
||||
export type GetConversationInfoParams = {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type SetConversationTitleParams = {
|
||||
id: number,
|
||||
new_title: string
|
||||
}
|
||||
|
||||
export type SetConversationPinnedParams = {
|
||||
id: number,
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export type RemoveConversationParams = {
|
||||
id?: number,
|
||||
ids?: number[]
|
||||
}
|
||||
|
||||
export const baseApi = {
|
||||
searchTitle: async (params: SearchTitleParams): Promise<SearchTitleResponse> => {
|
||||
const { data } = await toolkitApi.get('/title/search', { params })
|
||||
return data.data.titles
|
||||
},
|
||||
getConversationList: async (params: GetConversationListParams = {}): Promise<GetConversationListResponse> => {
|
||||
const { data } = await toolkitApi.get('/conversation/list', { params })
|
||||
return data.data.conversations
|
||||
},
|
||||
getConversationInfo: async (params: GetConversationInfoParams): Promise<ConversationInfo> => {
|
||||
const { data } = await toolkitApi.get(`/conversation/info`, { params })
|
||||
return data.data
|
||||
},
|
||||
setConversationTitle: async (params: SetConversationTitleParams): Promise<void> => {
|
||||
await toolkitApi.post(`/conversation/set_title`, params)
|
||||
},
|
||||
setConversationPinned: async (params: SetConversationPinnedParams): Promise<void> => {
|
||||
await toolkitApi.post(`/conversation/set_pinned`, params)
|
||||
},
|
||||
removeConversation: async (params: RemoveConversationParams): Promise<void> => {
|
||||
let apiParams: any = {}
|
||||
if (params.ids) {
|
||||
apiParams.ids = params.ids.join(',')
|
||||
} else if (params.id) {
|
||||
apiParams.id = params.id
|
||||
}
|
||||
await toolkitApi.post(`/conversation/remove`, apiParams)
|
||||
},
|
||||
}
|
@ -0,0 +1,190 @@
|
||||
import { RemoteError } from "@/errors/remoteError"
|
||||
import { toolkitApi } from "@/request/toolkit-api";
|
||||
import { useUserStore } from "@/stores"
|
||||
import { ChatCompleteChunkInfo, ExtractDocInfo } from "@/types/base";
|
||||
import { getWebSocketApiUrl } from "@/utils/request";
|
||||
|
||||
export type IndexPageParams = {
|
||||
title: string,
|
||||
collection?: boolean
|
||||
}
|
||||
|
||||
export type IndexPageOnProgress = (current: number, total: number, current_page: number, total_page: number) => any
|
||||
|
||||
export type IndexPageResponse = {
|
||||
indexUpdated?: boolean
|
||||
}
|
||||
|
||||
export type GetConversationChunkListParams = {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type GetConversationChunkListResponse = number[]
|
||||
|
||||
export type GetConversationChunkInfoParams = {
|
||||
chunk_id: number
|
||||
}
|
||||
|
||||
export type GetConversationChunkInfoResponse = {
|
||||
id: number,
|
||||
conversation_id: number,
|
||||
message_data: ChatCompleteChunkInfo,
|
||||
tokens: number,
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type ForkConversationParams = {
|
||||
id: number,
|
||||
message_id: string,
|
||||
new_title?: string
|
||||
}
|
||||
|
||||
export type ForkConversationResponse = {
|
||||
conversation_id: number
|
||||
}
|
||||
|
||||
export type StartChatCompleteResponse = {
|
||||
question_tokens: number,
|
||||
extract_doc: Record<string, ExtractDocInfo>,
|
||||
conversation_id: number,
|
||||
chunk_id: number,
|
||||
task_id: string,
|
||||
}
|
||||
|
||||
export type ChatCompleteServiceResponse = {
|
||||
message: string,
|
||||
message_tokens: number,
|
||||
total_tokens: number,
|
||||
finish_reason: string,
|
||||
question_message_id: string,
|
||||
response_message_id: string,
|
||||
delta_data: Record<string, any>,
|
||||
point_cost: number,
|
||||
}
|
||||
|
||||
export type GetPointCostParams = {
|
||||
question: string,
|
||||
extract_limit?: number,
|
||||
}
|
||||
|
||||
export const chatCompleteApi = {
|
||||
indexPage(params: IndexPageParams, onProgress: IndexPageOnProgress): Promise<IndexPageResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = getWebSocketApiUrl('/embedding_search/index_page', params)
|
||||
|
||||
let isFinished = false
|
||||
let ws = new WebSocket(url)
|
||||
ws.addEventListener('message', (event) => {
|
||||
let data = JSON.parse(event.data)
|
||||
if (data.event === 'progress') {
|
||||
onProgress(data.current, data.total, data.current_page, data.total_page)
|
||||
} else if (data.event === 'done') {
|
||||
isFinished = true
|
||||
resolve({
|
||||
indexUpdated: data.index_updated
|
||||
})
|
||||
} else if (data.event === 'error') {
|
||||
isFinished = true
|
||||
reject(new RemoteError(data.message, data.error))
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('error', (event) => {
|
||||
reject(new Error('连接异常:' + event.type))
|
||||
})
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
if (!isFinished) {
|
||||
reject(new Error('连接异常关闭:' + event.code + ' ' + event.reason))
|
||||
} else {
|
||||
resolve({
|
||||
indexUpdated: false
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async getConversationChunkList(params: GetConversationChunkListParams): Promise<GetConversationChunkListResponse> {
|
||||
const { data } = await toolkitApi.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 })
|
||||
return data.data
|
||||
},
|
||||
|
||||
async getPointCost(params: GetPointCostParams): Promise<number> {
|
||||
const { data } = await toolkitApi.post(`/chatcomplete/get_point_cost`, params)
|
||||
return data.data?.point_cost ?? -1
|
||||
},
|
||||
|
||||
async forkConversation(params: ForkConversationParams): Promise<ForkConversationResponse> {
|
||||
const { data } = await toolkitApi.post(`/chatcomplete/conversation/fork`, params)
|
||||
return data.data
|
||||
},
|
||||
|
||||
async startChatComplete(title: string, question: string, args: {
|
||||
conversationId?: number,
|
||||
extractLimit?: number,
|
||||
inCollection?: boolean,
|
||||
editMessageId?: [number, string]
|
||||
} = {}): Promise<StartChatCompleteResponse> {
|
||||
let params: any = {
|
||||
title: title,
|
||||
question: question
|
||||
}
|
||||
if (args.conversationId) {
|
||||
params.conversation_id = args.conversationId
|
||||
}
|
||||
if (args.extractLimit) {
|
||||
params.extract_limit = args.extractLimit
|
||||
}
|
||||
if (args.inCollection) {
|
||||
params.in_collection = args.inCollection
|
||||
}
|
||||
if (args.editMessageId) {
|
||||
params.edit_message_id = args.editMessageId.join(',')
|
||||
}
|
||||
const { data } = await toolkitApi.post('/chatcomplete/message', params)
|
||||
return data.data
|
||||
},
|
||||
|
||||
chatCompleteStreamOutput(taskId: string, onMessage: (deltaMessage: string) => any): Promise<ChatCompleteServiceResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = getWebSocketApiUrl('/chatcomplete/message/stream', {
|
||||
task_id: taskId
|
||||
})
|
||||
|
||||
let isFinished = false
|
||||
let ws = new WebSocket(url)
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (event.data.startsWith('+')) { // 实时输出的消息是以+开头的纯文本
|
||||
onMessage(event.data.substring(1))
|
||||
} else { // 其他消息是JSON格式
|
||||
let data = JSON.parse(event.data)
|
||||
if (data.event === 'connected') {
|
||||
onMessage(data.outputed_message)
|
||||
} else if (data.event === 'finished') {
|
||||
isFinished = true
|
||||
resolve(data.result)
|
||||
} else if (data.event === 'error') {
|
||||
isFinished = true
|
||||
reject(new RemoteError(data.message, data.error))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('error', (event) => {
|
||||
reject(new Error('连接异常:' + event.type))
|
||||
})
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
if (!isFinished) {
|
||||
reject(new Error('连接异常关闭:' + event.code + ' ' + event.reason))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { baseApi } from "./base";
|
||||
import { chatCompleteApi } from "./chatcomplete";
|
||||
|
||||
export const api = {
|
||||
base: baseApi,
|
||||
chat: chatCompleteApi
|
||||
};
|
@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TypeaheadModal'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TypeaheadItem } from '@/types/base'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
items?: TypeaheadItem[]
|
||||
loadItems?: (searchQuery: string | undefined) => Promise<TypeaheadItem[]>
|
||||
title?: string,
|
||||
presentingElement?: HTMLElement,
|
||||
backdropDismiss?: boolean,
|
||||
}>(), {
|
||||
items: () => [],
|
||||
title: '选择项目',
|
||||
backdropDismiss: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cancel'): any,
|
||||
(event: 'select', selectedValue: any, selectedItem: TypeaheadItem): any,
|
||||
(event: 'update:visible', visible: boolean): any
|
||||
}>()
|
||||
|
||||
const filteredItems = ref<TypeaheadItem[]>([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const modal = ref()
|
||||
|
||||
let themeColorMeta: HTMLMetaElement | null = document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
let originColor = themeColorMeta?.content;
|
||||
|
||||
const onOpen = () => {
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.content = '#000000';
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.content = originColor!;
|
||||
}
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const cancelChanges = () => {
|
||||
emit('cancel')
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const searchbarInput = () => {
|
||||
filterList(searchKeyword.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the rendered view with
|
||||
* the provided search query. If no
|
||||
* query is provided, all data
|
||||
* will be rendered.
|
||||
*/
|
||||
const filterList = async (searchQuery: string | undefined) => {
|
||||
if (props.loadItems) {
|
||||
loading.value = true
|
||||
filteredItems.value = await props.loadItems(searchQuery)
|
||||
} else {
|
||||
if (searchQuery === undefined) {
|
||||
/**
|
||||
* If no search query is defined,
|
||||
* return all options.
|
||||
*/
|
||||
filteredItems.value = props.items ?? []
|
||||
} else {
|
||||
/**
|
||||
* Otherwise, normalize the search
|
||||
* query and check to see which items
|
||||
* contain the search query as a substring.
|
||||
*/
|
||||
const normalizedQuery = searchQuery.toLowerCase()
|
||||
filteredItems.value = props.items.filter((item) => item.title.toLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const onSelectedItem = (item: TypeaheadItem) => {
|
||||
emit('select', item.value, item)
|
||||
}
|
||||
|
||||
let searchbarInputDebounce: any
|
||||
let inComposing = false
|
||||
const onSearchInputKeyDown = (ev: KeyboardEvent) => {
|
||||
loading.value = true
|
||||
if (ev.isComposing || inComposing) {
|
||||
return
|
||||
}
|
||||
if (ev.ctrlKey || ev.altKey || ev.metaKey) {
|
||||
return
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
searchbarInput()
|
||||
} else {
|
||||
if (searchbarInputDebounce) {
|
||||
clearTimeout(searchbarInputDebounce)
|
||||
}
|
||||
searchbarInputDebounce = setTimeout(() => {
|
||||
searchbarInput()
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
const onSearchInputCompositionStart = () => {
|
||||
loading.value = true
|
||||
inComposing = true
|
||||
if (searchbarInputDebounce) {
|
||||
clearTimeout(searchbarInputDebounce)
|
||||
}
|
||||
}
|
||||
|
||||
const onSearchInputCompositionEnd = () => {
|
||||
inComposing = false
|
||||
searchbarInputDebounce = setTimeout(() => {
|
||||
searchbarInput()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
watch(() => props.visible, (value, oldValue) => {
|
||||
if (value && !oldValue) {
|
||||
searchKeyword.value = ''
|
||||
filterList(undefined)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-modal ref="modal" :is-open="props.visible" :backdrop-dismiss="props.backdropDismiss"
|
||||
:presenting-element="props.presentingElement" @did-dismiss="onClose" @will-present="onOpen">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button @click="cancelChanges()">取消</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<loading-title :loading="loading">
|
||||
{{ $props.title }}
|
||||
</loading-title>
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
<ion-toolbar>
|
||||
<ion-searchbar v-model="searchKeyword" placeholder="搜索..." :debounce="0" @keydown="onSearchInputKeyDown"
|
||||
@compositionstart="onSearchInputCompositionStart"
|
||||
@compositionend="onSearchInputCompositionEnd"></ion-searchbar>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content color="light" class="ion-padding">
|
||||
<ion-list :inset="true" lines="full">
|
||||
<ion-item v-for="item in filteredItems" @click="onSelectedItem(item)" :key="item.key" button
|
||||
:detail="false">
|
||||
<ion-label>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p v-if="item.subtitle">{{ item.subtitle }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
ion-modal ion-content {
|
||||
--background: var(--ion-color-light);
|
||||
|
||||
&.ion-padding {
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
--padding-top: 8px;
|
||||
--padding-bottom: 8px;
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ion-list-header {
|
||||
font-size: 1.2rem;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
ion-list {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,13 @@
|
||||
export class RemoteError extends Error {
|
||||
public code: string = 'unknown_error'
|
||||
public info?: string
|
||||
public title?: string
|
||||
|
||||
constructor(message: string, data: any) {
|
||||
super(message)
|
||||
|
||||
if (data.code) this.code = data.code
|
||||
if (data.info) this.info = data.info
|
||||
if (data.title) this.title = data.title
|
||||
}
|
||||
}
|
@ -1,264 +0,0 @@
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'ToolkitLayout'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStore } from '@/stores'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
add,
|
||||
sparkles
|
||||
} from 'ionicons/icons'
|
||||
import router from '@/router'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const selectedIndex = ref(-1)
|
||||
const appPages = ref([
|
||||
{
|
||||
title: '未命名对话',
|
||||
extract: '暂无消息',
|
||||
type: 'chatcomplete',
|
||||
id: 1,
|
||||
},
|
||||
])
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return appPages.value.map((page) => {
|
||||
return {
|
||||
...page,
|
||||
url: `/toolkit/${encodeURIComponent(store.state.pageTitle)}/${page.type}?id=${page.id}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const backToToolkitIndex = () => {
|
||||
router.replace(`/toolkit/${encodeURIComponent(store.state.pageTitle)}`)
|
||||
selectedIndex.value = -1
|
||||
}
|
||||
|
||||
const pointsText = computed(() => {
|
||||
const points = store.state.user.points ?? 0
|
||||
if (points === -2) {
|
||||
return '加载中'
|
||||
} else if (points === -1) {
|
||||
return '∞'
|
||||
} else {
|
||||
return points
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const route = useRoute()
|
||||
|
||||
// 设置当前页面标题
|
||||
store.commit('setPageTitle', route.params.title)
|
||||
|
||||
// 设置当前选中项
|
||||
const pathInfo = route.path.split('/').splice(3)
|
||||
if (pathInfo !== undefined && pathInfo.length >= 2) {
|
||||
const type = pathInfo[0]
|
||||
const conversationId = parseInt(pathInfo[1])
|
||||
|
||||
selectedIndex.value = menuItems.value.findIndex((page) => page.type === type && page.id === conversationId)
|
||||
console.log('selectedIndex', selectedIndex.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header>
|
||||
<ion-toolbar color="dark">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button default-href="/" text="页面列表" />
|
||||
</ion-buttons>
|
||||
<ion-title>{{ store.state.pageTitle }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-chip color="light">
|
||||
<ion-icon :icon="sparkles" title="星屑"></ion-icon>
|
||||
<ion-label color="light">{{ pointsText }}</ion-label>
|
||||
</ion-chip>
|
||||
<ion-avatar class="header-avatar">
|
||||
<img :src="store.getters.userDisplayAvatar" :title="store.getters.userDisplayName" :alt="store.getters.userDisplayName" />
|
||||
</ion-avatar>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-split-pane content-id="main-content">
|
||||
<ion-menu content-id="main-content" type="overlay">
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar v-if="store.state.pageTitle">
|
||||
<ion-title>
|
||||
<loading-title :loading="true">
|
||||
对话列表
|
||||
</loading-title>
|
||||
</ion-title>
|
||||
<ion-buttons slot="primary">
|
||||
<ion-button title="新对话" @click="backToToolkitIndex">
|
||||
<ion-icon slot="icon-only" :ios="add" :md="add"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content :fullscreen="true">
|
||||
<ion-list id="conversation-list" v-if="store.state.pageTitle">
|
||||
<ion-list-header>
|
||||
<ion-label class="conversation-list-title">对话列表</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-menu-toggle :auto-hide="false" v-for="(p, i) in menuItems" :key="i">
|
||||
<ion-item @click="selectedIndex = i" router-direction="root" :router-link="p.url" :detail="false"
|
||||
lines="full" class="hydrated" :class="{ selected: selectedIndex === i }">
|
||||
<ion-label>
|
||||
<h2>{{ p.title }}</h2>
|
||||
<p>{{ p.extract }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-menu>
|
||||
<ion-router-outlet id="main-content"></ion-router-outlet>
|
||||
</ion-split-pane>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ion-split-pane {
|
||||
--side-max-width: 380px;
|
||||
}
|
||||
|
||||
ion-menu {
|
||||
ion-content {
|
||||
--background: var(--ion-item-background, var(--ion-background-color, #fff));
|
||||
}
|
||||
|
||||
&.md {
|
||||
ion-content {
|
||||
--padding-start: 8px;
|
||||
--padding-end: 8px;
|
||||
--padding-top: 20px;
|
||||
--padding-bottom: 20px;
|
||||
}
|
||||
|
||||
ion-list {
|
||||
padding: 0 0 20px 0;
|
||||
|
||||
&#conversation-list {
|
||||
border-bottom: 1px solid var(--ion-color-step-150, #d7d8da);
|
||||
|
||||
ion-list-header {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
|
||||
min-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-note {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
ion-list-header,
|
||||
ion-note {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
border-radius: 4px;
|
||||
|
||||
ion-icon {
|
||||
color: #616e7e;
|
||||
}
|
||||
|
||||
ion-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
--background: rgba(var(--ion-color-primary-rgb), 0.14);
|
||||
|
||||
ion-icon {
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
&.ios {
|
||||
ion-content {
|
||||
--padding-bottom: 20px;
|
||||
}
|
||||
|
||||
ion-list {
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
ion-note {
|
||||
line-height: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--min-height: 50px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 24px;
|
||||
color: #73849a;
|
||||
}
|
||||
|
||||
&.selected ion-icon {
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
ion-list-header,
|
||||
ion-note {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
ion-note {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-note {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
|
||||
color: var(--ion-color-medium-shade);
|
||||
}
|
||||
|
||||
ion-toolbar {
|
||||
ion-buttons {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
ion-item.selected {
|
||||
--background: var(--ion-color-primary);
|
||||
--color: var(--ion-color-primary-contrast);
|
||||
--ion-text-color-rgb: var(--ion-color-primary-contrast-rgb);
|
||||
}
|
||||
|
||||
.conversation-list-title {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,7 @@
|
||||
import 'pinia'
|
||||
|
||||
declare module 'pinia' {
|
||||
export interface PiniaCustomProperties {
|
||||
with(): Promise<void>
|
||||
}
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import { api } from "@/api";
|
||||
import { ConversationInfo } from "@/types/base";
|
||||
import { defineStore } from "pinia";
|
||||
import { usePageStore } from "./page";
|
||||
import { useUserStore } from ".";
|
||||
|
||||
export type ConversationState = {
|
||||
list: ConversationInfo[],
|
||||
currentId: number,
|
||||
isLoaded: boolean
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('conversation', {
|
||||
state: (): ConversationState => ({
|
||||
list: [],
|
||||
currentId: -1,
|
||||
isLoaded: false,
|
||||
}),
|
||||
getters: {
|
||||
current: (state): ConversationInfo | undefined => {
|
||||
if (state.currentId >= 0) {
|
||||
return state.list.find((item) => item.id === state.currentId)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
newConversation(module: string, title: string) {
|
||||
this.list.push({
|
||||
id: 0,
|
||||
title,
|
||||
module,
|
||||
pinned: false,
|
||||
updated_at: new Date().getTime(),
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
updateById(id: number, data: Partial<ConversationInfo>) {
|
||||
let currentItem = this.list.find((item) => item.id === id);
|
||||
if (currentItem) {
|
||||
Object.assign(currentItem, data);
|
||||
}
|
||||
},
|
||||
async load() {
|
||||
const pageStore = usePageStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (!userStore.loggedIn) {
|
||||
this.list = []
|
||||
this.isLoaded = true
|
||||
return
|
||||
}
|
||||
|
||||
let queryParams: any = {}
|
||||
|
||||
/* 暂时不使用当前页面区分会话
|
||||
if (pageStore.title) {
|
||||
queryParams.title = pageStore.title
|
||||
}
|
||||
*/
|
||||
|
||||
const res = await api.base.getConversationList(queryParams)
|
||||
this.list = res
|
||||
this.isLoaded = true
|
||||
},
|
||||
async editTitle(id: number, newTitle: string) {
|
||||
await api.base.setConversationTitle({
|
||||
id,
|
||||
new_title: newTitle
|
||||
})
|
||||
this.updateById(id, {
|
||||
title: newTitle,
|
||||
updated_at: Math.ceil(Date.now() / 1000)
|
||||
})
|
||||
this.sort()
|
||||
},
|
||||
async remove(id: number | number[]) {
|
||||
if (Array.isArray(id)) {
|
||||
await api.base.removeConversation({
|
||||
ids: id
|
||||
})
|
||||
this.list = this.list.filter((item) => !id.includes(item.id))
|
||||
} else {
|
||||
await api.base.removeConversation({
|
||||
id
|
||||
})
|
||||
this.list = this.list.filter((item) => item.id !== id)
|
||||
}
|
||||
// 重置当前选中
|
||||
if (!this.list.find((item) => item.id === this.currentId)) {
|
||||
this.currentId = -1
|
||||
}
|
||||
},
|
||||
async pin(id: number, pinned: boolean) {
|
||||
await api.base.setConversationPinned({
|
||||
id,
|
||||
pinned
|
||||
})
|
||||
this.updateById(id, {
|
||||
pinned,
|
||||
updated_at: Math.ceil(Date.now() / 1000)
|
||||
})
|
||||
this.sort()
|
||||
},
|
||||
sort() {
|
||||
this.list.sort((a, b) => {
|
||||
if (a.pinned && b.pinned || !a.pinned && !b.pinned) { // 置顶或不置顶对话内部,按更新时间排序
|
||||
return b.updated_at - a.updated_at
|
||||
} else if (a.pinned) { // 将置顶的会话排在前面
|
||||
return -1
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export type GuideState = {
|
||||
helpForPin: boolean
|
||||
}
|
||||
|
||||
export const useGuideStore = defineStore('guide', {
|
||||
state: (): GuideState => ({
|
||||
helpForPin: false,
|
||||
}),
|
||||
persist: {
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
key: 'store_guide',
|
||||
storage: localStorage
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
@ -1,37 +1,5 @@
|
||||
import { Store, createStore, useStore as baseUseStore, Module } from 'vuex';
|
||||
import { UserState, user } from './user';
|
||||
import { PageState, page } from './page';
|
||||
|
||||
export interface RootState {
|
||||
token: string,
|
||||
}
|
||||
|
||||
export type State = RootState & {
|
||||
user: UserState,
|
||||
page: PageState,
|
||||
}
|
||||
|
||||
export const storeKey: InjectionKey<Store<State>> = Symbol()
|
||||
|
||||
export const store = createStore<RootState>({
|
||||
state() {
|
||||
return {
|
||||
token: ''
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setToken(state, token) {
|
||||
state.token = token
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
user,
|
||||
page
|
||||
}
|
||||
})
|
||||
|
||||
export const MUTATION_SET_TOKEN = 'setToken'
|
||||
|
||||
export function useStore() {
|
||||
return baseUseStore(storeKey)
|
||||
}
|
||||
export * from './page'
|
||||
export * from './user'
|
||||
export * from './conversation'
|
||||
export * from './guide'
|
||||
export * from './settings'
|
@ -1,64 +1,75 @@
|
||||
import { Module } from "vuex"
|
||||
import { RootState, useStore } from "."
|
||||
import { toolkitApi } from "@/request/toolkit-api"
|
||||
import { showError } from "@/utils/dialog"
|
||||
|
||||
export class PageLoader extends InitLoader {
|
||||
public isLoaded() {
|
||||
const store = useStore()
|
||||
return !!store.state.page.pageId
|
||||
}
|
||||
|
||||
protected async _load() {
|
||||
const store = useStore()
|
||||
await store.dispatch(ACTION_LOAD_PAGE_INFO)
|
||||
}
|
||||
}
|
||||
import { defineStore } from "pinia"
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
export type PageState = {
|
||||
title: string,
|
||||
displayTitle: string | null,
|
||||
pageId: number | null,
|
||||
isLoaded: boolean,
|
||||
}
|
||||
|
||||
export const page: Module<PageState, RootState> = {
|
||||
state: {
|
||||
const pageStoreCache = new LRUCache<string, PageState>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 10, // 10 minutes
|
||||
})
|
||||
|
||||
export const usePageStore = defineStore('page', {
|
||||
state: (): PageState => ({
|
||||
title: '',
|
||||
displayTitle: null,
|
||||
pageId: null,
|
||||
},
|
||||
|
||||
isLoaded: false,
|
||||
}),
|
||||
getters: {
|
||||
pageDisplayTitle(state) {
|
||||
pageDisplayTitle: (state) => {
|
||||
return state.displayTitle || state.title
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setPageInfo(state, pageInfo: Partial<PageState>) {
|
||||
if (pageInfo.title) state.title = pageInfo.title
|
||||
if (pageInfo.displayTitle !== undefined) state.displayTitle = pageInfo.displayTitle
|
||||
if (pageInfo.pageId !== undefined) state.pageId = pageInfo.pageId
|
||||
},
|
||||
collectionTitle: (state) => {
|
||||
let sepPos = state.title.indexOf('/')
|
||||
if (sepPos === -1) {
|
||||
return state.title
|
||||
}
|
||||
return state.title.substring(0, sepPos)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loadPageInfo({ commit, state }) {
|
||||
async load() {
|
||||
// Load from cache
|
||||
if (pageStoreCache.has(this.title)) {
|
||||
const cache = pageStoreCache.get(this.title)
|
||||
if (cache) {
|
||||
this.title = cache.title
|
||||
this.displayTitle = cache.displayTitle
|
||||
this.pageId = cache.pageId
|
||||
this.isLoaded = cache.isLoaded
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Load from server
|
||||
const res = await toolkitApi.get('/title/info', {
|
||||
params: {
|
||||
title: state.title,
|
||||
title: this.title,
|
||||
}
|
||||
})
|
||||
if (res.data.status == 1 && res.data.data) {
|
||||
const pageInfo = res.data.data
|
||||
commit(MUTATION_SET_PAGE_INFO, {
|
||||
title: pageInfo.title,
|
||||
pageId: pageInfo.page_id,
|
||||
this.pageId = pageInfo.page_id
|
||||
this.title = pageInfo.title
|
||||
this.displayTitle = pageInfo.display_title
|
||||
|
||||
pageStoreCache.set(this.title, {
|
||||
title: this.title,
|
||||
displayTitle: this.displayTitle,
|
||||
pageId: this.pageId,
|
||||
isLoaded: true,
|
||||
})
|
||||
} else {
|
||||
throw new Error('无法加载页面信息:' + res.data.error)
|
||||
showError('无法加载页面信息:' + res.data.error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MUTATION_SET_PAGE_INFO = 'setPageInfo';
|
||||
|
||||
export const ACTION_LOAD_PAGE_INFO = 'loadPageInfo';
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export type SettingsState = {
|
||||
docExtractLimit: number,
|
||||
collectionMode: boolean
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: (): SettingsState => ({
|
||||
docExtractLimit: 10,
|
||||
collectionMode: false,
|
||||
}),
|
||||
persist: {
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
key: 'store_settings',
|
||||
storage: localStorage
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
@ -1,90 +1,83 @@
|
||||
import { Module } from "vuex"
|
||||
import { RootState, useStore } from "."
|
||||
import { decodeJwt } from "@/utils/jwt"
|
||||
import { toolkitApi } from "@/request/toolkit-api"
|
||||
import { showError } from "@/utils/dialog"
|
||||
|
||||
export class UserLoader extends InitLoader {
|
||||
public isLoaded() {
|
||||
const store = useStore()
|
||||
return !!store.state.user.uid
|
||||
}
|
||||
|
||||
protected async _load() {
|
||||
const store = useStore()
|
||||
await store.dispatch(ACTION_LOAD_USER_INFO)
|
||||
}
|
||||
}
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export type UserState = {
|
||||
token: string,
|
||||
uid: string,
|
||||
userName: string,
|
||||
nickName: string,
|
||||
avatar: string,
|
||||
points: number,
|
||||
loader: UserLoader,
|
||||
permissions: string[],
|
||||
isLoaded: boolean,
|
||||
lastLoadTime?: number,
|
||||
}
|
||||
|
||||
export const user: Module<UserState, RootState> = {
|
||||
state: {
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
token: '',
|
||||
uid: '',
|
||||
userName: '',
|
||||
nickName: '',
|
||||
avatar: '',
|
||||
points: -2,
|
||||
loader: new UserLoader(),
|
||||
},
|
||||
permissions: [],
|
||||
|
||||
isLoaded: false,
|
||||
lastLoadTime: undefined,
|
||||
}),
|
||||
getters: {
|
||||
userDisplayName(state) {
|
||||
displayName: (state) => {
|
||||
return state.nickName || state.userName
|
||||
},
|
||||
userDisplayAvatar(state) {
|
||||
displayAvatar: (state) => {
|
||||
return state.avatar || import.meta.env.VITE_DEFAULT_AVATAR
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setUserInfo(state, user: Partial<UserState>) {
|
||||
if (user.uid) state.uid = user.uid
|
||||
if (user.userName) state.userName = user.userName
|
||||
if (user.nickName) state.nickName = user.nickName
|
||||
if (user.avatar) state.avatar = user.avatar
|
||||
if (user.points === 0 || user.points) state.points = user.points
|
||||
},
|
||||
loggedIn: (state) => {
|
||||
return !!state.uid
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loadUserInfoFromToken({ commit }, token) {
|
||||
setPartialUserInfo(user: Partial<UserState>) {
|
||||
if (user.uid) this.uid = user.uid
|
||||
if (user.userName) this.userName = user.userName
|
||||
if (user.nickName) this.nickName = user.nickName
|
||||
if (user.avatar) this.avatar = user.avatar
|
||||
if (user.points === 0 || user.points) this.points = user.points
|
||||
},
|
||||
async loadUserInfoFromToken(token: string) {
|
||||
const { payload } = decodeJwt(token)
|
||||
commit(MUTATION_SET_USER_INFO, {
|
||||
uid: payload.sub,
|
||||
userName: payload.username,
|
||||
nickName: payload.nickname,
|
||||
})
|
||||
this.token = token
|
||||
this.uid = payload.sub
|
||||
this.userName = payload.username
|
||||
this.nickName = payload.nickname
|
||||
|
||||
// add default permissions
|
||||
this.permissions = ['chatcomplete', 'imagen']
|
||||
},
|
||||
async loadUserInfo({ commit, state }) {
|
||||
async load() {
|
||||
const res = await toolkitApi.get('/user/info')
|
||||
if (res.data.status == 1 && res.data.data) {
|
||||
const userInfo = res.data.data
|
||||
commit(MUTATION_SET_USER_INFO, {
|
||||
uid: userInfo.userid,
|
||||
userName: userInfo.username,
|
||||
nickName: userInfo.realname,
|
||||
avatar: userInfo.avatar,
|
||||
points: userInfo.points,
|
||||
});
|
||||
|
||||
this.uid = userInfo.userid
|
||||
this.userName = userInfo.username
|
||||
this.nickName = userInfo.realname
|
||||
this.avatar = userInfo.avatar
|
||||
this.points = userInfo.points
|
||||
|
||||
this.lastLoadTime = new Date().getTime();
|
||||
|
||||
(window as any)._loggedIn = true
|
||||
} else {
|
||||
throw new Error('无法加载用户信息:' + res.data.error)
|
||||
}
|
||||
},
|
||||
async loadUserPoints({ commit, state }) {
|
||||
// TODO: Load user points from server
|
||||
async refresh() {
|
||||
if (this.lastLoadTime && new Date().getTime() - this.lastLoadTime < 60 * 10 * 1000) {
|
||||
return
|
||||
}
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MUTATION_SET_USER_INFO = 'setUserInfo';
|
||||
|
||||
export const ACTION_LOAD_USER_INFO_FROM_TOKEN = 'loadUserInfoFromToken';
|
||||
export const ACTION_LOAD_USER_INFO = 'loadUserInfo';
|
||||
export const ACTION_LOAD_USER_POINTS = 'loadUserPoints';
|
||||
},
|
||||
});
|
@ -0,0 +1,29 @@
|
||||
.text-selectable {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
user-select: text;
|
||||
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.help-block {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
text-overflow: inherit;
|
||||
overflow: inherit;
|
||||
color: rgba(var(--ion-text-color-rgb, 0, 0, 0), 0.4);
|
||||
}
|
||||
|
||||
.list-help-block {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.list-ios.list-inset.with-help-block {
|
||||
margin-bottom: 8px;
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
export type ConversationInfo = {
|
||||
id: number,
|
||||
module: string,
|
||||
page_title?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
description?: string,
|
||||
rev_id?: number,
|
||||
updated_at: number,
|
||||
pinned: boolean,
|
||||
extra: any,
|
||||
new_conversation?: boolean,
|
||||
}
|
||||
|
||||
export type ExtractDocInfo = {
|
||||
id: number,
|
||||
sha1: string,
|
||||
text: string,
|
||||
text_len: number,
|
||||
markdown?: string,
|
||||
markdown_len?: string,
|
||||
distance: number,
|
||||
}
|
||||
|
||||
export type ChatCompleteMessage = {
|
||||
id?: string,
|
||||
role: string,
|
||||
content: string,
|
||||
tokens: number,
|
||||
time?: number,
|
||||
streaming?: boolean,
|
||||
}
|
||||
|
||||
export type ChatCompleteChunkInfo = ChatCompleteMessage[]
|
||||
|
||||
export type TypeaheadItem = {
|
||||
key: any;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: any;
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
export const moduleConversationRoute: Record<string, string> = {
|
||||
chatcomplete: 'toolbox_chatcomplete',
|
||||
}
|
||||
|
||||
export const moduleNewConversationRoute: Record<string, string> = {
|
||||
chatcomplete: 'toolbox_chatcomplete_new',
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { IonRouterOutlet } from "@ionic/vue";
|
||||
|
||||
export type IonRouterOutletInstance = InstanceType<typeof IonRouterOutlet>
|
@ -0,0 +1,20 @@
|
||||
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()
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export class ServerError extends Error {
|
||||
constructor(message: string, public response: any) {
|
||||
constructor(message: string, public response: any, public detail: any = {}) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
|
@ -1,39 +0,0 @@
|
||||
class InitLoader {
|
||||
private loading = false;
|
||||
private listeners: CallableFunction[][] = [];
|
||||
|
||||
public isLoaded() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async load() {
|
||||
try {
|
||||
this.loading = true;
|
||||
await this._load();
|
||||
this.loading = false;
|
||||
|
||||
this.listeners.forEach(listener => listener[0]());
|
||||
} catch (e) {
|
||||
this.listeners.forEach(listener => listener[1](e));
|
||||
}
|
||||
}
|
||||
|
||||
public reset() { }
|
||||
|
||||
public use(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.isLoaded()) {
|
||||
resolve();
|
||||
} else {
|
||||
if (!this.loading) {
|
||||
this.load();
|
||||
}
|
||||
this.listeners.push([resolve, reject]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async _load() {
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export function setPageTitle(...title: string[]) {
|
||||
let newTitle = title.join(' - ')
|
||||
if (newTitle === '') {
|
||||
document.title = import.meta.env.VITE_APP_NAME
|
||||
} else {
|
||||
document.title = newTitle + ' - ' + import.meta.env.VITE_APP_NAME
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import { useUserStore } from "@/stores"
|
||||
|
||||
export function getWebSocketApiUrl(path: string, query: Record<string, any> = {}) {
|
||||
const userStore = useUserStore()
|
||||
let token = userStore.token
|
||||
let baseUrl = import.meta.env.VITE_TOOLKIT_WS_API_ENDPOINT
|
||||
let url = new URL(baseUrl, location.href)
|
||||
|
||||
if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:'
|
||||
} else {
|
||||
url.protocol = 'ws:'
|
||||
}
|
||||
|
||||
url.pathname += path
|
||||
url.searchParams.set('token', token)
|
||||
for (let key in query) {
|
||||
url.searchParams.set(key, query[key])
|
||||
}
|
||||
|
||||
return url.href
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
import { useConversationStore } from "@/stores";
|
||||
import { PiniaPluginContext, Store } from "pinia";
|
||||
|
||||
export class InitLoaderError extends Error {
|
||||
constructor(message: string, public readonly srcError: Error, public readonly isFirst = false) {
|
||||
super(message);
|
||||
this.name = 'InitLoaderError';
|
||||
}
|
||||
}
|
||||
|
||||
export function piniaInitLoader(context: PiniaPluginContext) {
|
||||
let loading = false;
|
||||
let listeners: CallableFunction[][] = [];
|
||||
|
||||
const loadStore = async () => {
|
||||
try {
|
||||
loading = true;
|
||||
await context.store.load();
|
||||
loading = false;
|
||||
listeners.forEach((listener) => listener[0]());
|
||||
} catch (e: any) {
|
||||
loading = false;
|
||||
listeners.forEach((listener, index) => {
|
||||
const err = new InitLoaderError('Load store failed', e, index === 0);
|
||||
listener[1](err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
with: () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if ('isLoaded' in context.store && 'load' in context.store) {
|
||||
if (context.store.isLoaded) {
|
||||
resolve();
|
||||
} else {
|
||||
if (!loading) {
|
||||
loadStore();
|
||||
}
|
||||
listeners.push([resolve, reject]);
|
||||
}
|
||||
} else {
|
||||
reject(new Error('This store is not support init loader'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all stores
|
||||
* @param stores Pinina stores
|
||||
* @returns
|
||||
*/
|
||||
export function withStore(...stores: Store[]) {
|
||||
return Promise.all(stores.map((store) => store.with()));
|
||||
}
|
||||
|
||||
export function getRecommandTitles() {
|
||||
const conversationStore = useConversationStore()
|
||||
let recommandTitles: string[] = []
|
||||
conversationStore.list.forEach((item) => {
|
||||
if (item.page_title && !recommandTitles.includes(item.page_title)) {
|
||||
recommandTitles.push(item.page_title)
|
||||
}
|
||||
})
|
||||
|
||||
return recommandTitles
|
||||
}
|
@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="@ionic/vue" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
@ -0,0 +1 @@
|
||||
{"version":"5.1.3"}
|
Loading…
Reference in New Issue