完成对话功能

master
落雨楓 2 years ago
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

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>异世界写作助手</title>
<title>异世界工具箱</title>
<base href="/" />
@ -16,9 +16,11 @@
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
<meta name="theme-color" content="#222428" />
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="异世界写作助手" />
<meta name="apple-mobile-web-app-title" content="异世界工具箱" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>

61
package-lock.json generated

@ -22,10 +22,10 @@
"ionicons": "^7.0.0",
"js-base64": "^3.7.5",
"markdown-it": "^13.0.1",
"pinia": "^2.0.36",
"vue": "^3.2.45",
"vue-router": "^4.1.6",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vuex": "^4.0.2"
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@capacitor/cli": "4.7.3",
@ -4888,6 +4888,50 @@
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "2.0.36",
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.0.36.tgz",
"integrity": "sha512-4UKApwjlmJH+VuHKgA+zQMddcCb3ezYnyewQ9NVrsDqZ/j9dMv5+rh+1r48whKNdpFkZAWVxhBp5ewYaYX9JcQ==",
"dependencies": {
"@vue/devtools-api": "^6.5.0",
"vue-demi": "*"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.6.14 || ^3.2.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.0",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.0.tgz",
"integrity": "sha512-gt58r2ogsNQeVoQ3EhoUAvUsH9xviydl0dWJj7dabBC/2L4uBId7ujtCwDRD0JhkGsV1i0CtfLAeyYKBht9oWg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/pkg-types": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.2.tgz",
@ -5856,7 +5900,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6403,17 +6447,6 @@
"vue": "^3.2.0"
}
},
"node_modules/vuex": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/vuex/-/vuex-4.0.2.tgz",
"integrity": "sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q==",
"dependencies": {
"@vue/devtools-api": "^6.0.0-beta.11"
},
"peerDependencies": {
"vue": "^3.0.2"
}
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",

@ -18,31 +18,34 @@
"@capacitor/haptics": "4.1.0",
"@capacitor/keyboard": "4.1.1",
"@capacitor/status-bar": "4.1.1",
"@ionic/vue": "^7.0.0",
"@ionic/vue-router": "^7.0.0",
"@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.0.0",
"ionicons": "^7.1.2",
"js-base64": "^3.7.5",
"lru-cache": "^10.0.0",
"markdown-it": "^13.0.1",
"vue": "^3.2.45",
"pinia": "^2.0.36",
"pinia-plugin-persist": "^1.0.0",
"vue": "^3.3.4",
"vue-router": "^4.1.6",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vuex": "^4.0.2"
"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",
"@vue/runtime-core": "^3.3.4",
"@vue/test-utils": "^2.3.0",
"cypress": "^12.7.0",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.9.0",
"jsdom": "^21.1.0",
"sass": "^1.62.0",
"typescript": "^4.9.3",
"typescript": "^5.1.3",
"unplugin-auto-import": "^0.15.3",
"unplugin-vue-components": "^0.24.1",
"vite": "^4.1.0",

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

@ -1,19 +1,25 @@
<script setup lang="ts">
<script lang="ts">
export default {
name: 'App'
};
</script>
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import { useStore } from './stores';
import { ACTION_LOAD_USER_INFO, ACTION_LOAD_USER_INFO_FROM_TOKEN } from './stores/user';
import { MUTATION_SET_PAGE_INFO } from './stores/page';
import { ACTION_LOAD_PAGE_INFO } from './stores/page';
import { useUserStore } from './stores'
import { IonRouterOutletInstance } from './types/instance';
const store = useStore()
const route = useRoute()
const userStore = useUserStore()
const state = reactive({
storeLoaded: false,
errorMessage: null as string | null,
})
const mainRouterOutletRef = ref<IonRouterOutletInstance>()
const initLoginState = async () => {
const route = useRoute()
try {
if (route.query.token) {
let tokenParam = route.query.token
@ -24,33 +30,24 @@ const initLoginState = async () => {
token = tokenParam
}
await store.dispatch(ACTION_LOAD_USER_INFO_FROM_TOKEN, token)
await userStore.loadUserInfoFromToken(token)
sessionStorage.setItem('aiassistant_token', token)
await store.dispatch(ACTION_LOAD_USER_INFO)
await userStore.load()
} else {
let token = sessionStorage.getItem('aiassistant_token')
if (token) {
await store.dispatch(ACTION_LOAD_USER_INFO_FROM_TOKEN, token)
await store.dispatch(ACTION_LOAD_USER_INFO)
await userStore.loadUserInfoFromToken(token)
await userStore.load()
} else {
state.errorMessage = '登录信息不存在,请通过百科页面上的链接重新进入此页面。'
//
// state.errorMessage = ''
}
}
} catch(err: any) {
console.error(err)
state.errorMessage = err.message
}
}
const initPageState = async () => {
const route = useRoute()
try {
if (route.query.title) {
await store.commit(MUTATION_SET_PAGE_INFO, {
title: route.query.title as string
})
await store.dispatch(ACTION_LOAD_PAGE_INFO)
}
// URLtoken
let newUrl = new URL(location.href);
newUrl.searchParams.delete('token');
history.replaceState(null, '', newUrl.href);
state.storeLoaded = true
} catch(err: any) {
console.error(err)
state.errorMessage = err.message
@ -59,15 +56,16 @@ const initPageState = async () => {
onMounted(async () => {
await initLoginState()
await initPageState()
})
provide('mainRouterOutlet', mainRouterOutletRef)
</script>
<template>
<ion-app>
<transition-group name="fade">
<ion-router-outlet v-if="state.storeLoaded"></ion-router-outlet>
<div id="status-container" v-else>
<ion-router-outlet v-if="state.storeLoaded" ref="mainRouterOutletRef"></ion-router-outlet>
<div class="status-container" v-else>
<div v-if="state.errorMessage">
<strong>提示</strong>
<p>{{ state.errorMessage }}</p>

@ -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
};

@ -45,6 +45,7 @@ declare global {
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']

@ -9,10 +9,10 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ChatCompleteSettingsModal: typeof import('./components/ChatCompleteSettingsModal.vue')['default']
ChatMessage: typeof import('./components/ChatMessage.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp']
IonAvatar: typeof import('@ionic/vue')['IonAvatar']
IonBackButton: typeof import('@ionic/vue')['IonBackButton']
IonButton: typeof import('@ionic/vue')['IonButton']
IonButtons: typeof import('@ionic/vue')['IonButtons']
IonCard: typeof import('@ionic/vue')['IonCard']
@ -20,6 +20,7 @@ declare module '@vue/runtime-core' {
IonCardHeader: typeof import('@ionic/vue')['IonCardHeader']
IonCardSubtitle: typeof import('@ionic/vue')['IonCardSubtitle']
IonCardTitle: typeof import('@ionic/vue')['IonCardTitle']
IonCheckbox: typeof import('@ionic/vue')['IonCheckbox']
IonChip: typeof import('@ionic/vue')['IonChip']
IonCol: typeof import('@ionic/vue')['IonCol']
IonContent: typeof import('@ionic/vue')['IonContent']
@ -27,28 +28,36 @@ declare module '@vue/runtime-core' {
IonGrid: typeof import('@ionic/vue')['IonGrid']
IonHeader: typeof import('@ionic/vue')['IonHeader']
IonIcon: typeof import('@ionic/vue')['IonIcon']
IonInput: typeof import('@ionic/vue')['IonInput']
IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
IonItem: typeof import('@ionic/vue')['IonItem']
IonItemOption: typeof import('@ionic/vue')['IonItemOption']
IonItemOptions: typeof import('@ionic/vue')['IonItemOptions']
IonItemSliding: typeof import('@ionic/vue')['IonItemSliding']
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList']
IonListHeader: typeof import('@ionic/vue')['IonListHeader']
IonMenu: typeof import('@ionic/vue')['IonMenu']
IonMenuButton: typeof import('@ionic/vue')['IonMenuButton']
IonMenuToggle: typeof import('@ionic/vue')['IonMenuToggle']
IonModal: typeof import('@ionic/vue')['IonModal']
IonPage: typeof import('@ionic/vue')['IonPage']
IonPopover: typeof import('@ionic/vue')['IonPopover']
IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
IonRow: typeof import('@ionic/vue')['IonRow']
IonSearchbar: typeof import('@ionic/vue')['IonSearchbar']
IonSelect: typeof import('@ionic/vue')['IonSelect']
IonSelectOption: typeof import('@ionic/vue')['IonSelectOption']
IonSpinner: typeof import('@ionic/vue')['IonSpinner']
IonSplitPane: typeof import('@ionic/vue')['IonSplitPane']
IonTextarea: typeof import('@ionic/vue')['IonTextarea']
IonTitle: typeof import('@ionic/vue')['IonTitle']
IonToggle: typeof import('@ionic/vue')['IonToggle']
IonToolbar: typeof import('@ionic/vue')['IonToolbar']
LoadingTitle: typeof import('./components/LoadingTitle.vue')['default']
Markdown: typeof import('./components/Markdown.vue')['default']
MarkdownParser: typeof import('./components/MarkdownParser.vue')['default']
MessageList: typeof import('./components/MessageList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TypeaheadModal: typeof import('./components/TypeaheadModal.vue')['default']
}
}

@ -0,0 +1,126 @@
<script lang="ts">
export default {
name: 'ChatCompleteSettingsModal'
}
</script>
<script lang="ts" setup>
import { useSettingsStore } from '@/stores'
import { showConfirm } from '@/utils/dialog'
const props = defineProps<{
visible: boolean,
presentingElement?: HTMLElement,
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): any,
(event: 'close'): any,
(event: 'manualUpdateIndex'): any
}>()
const closeModal = () => {
emit('update:visible', false)
}
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('close')
}
const onManualUpdateIndex = async () => {
if (await showConfirm('确定要更新当前页面索引吗?')) {
emit('manualUpdateIndex')
}
}
const settingsStore = useSettingsStore()
</script>
<template>
<ion-modal ref="modal" :is-open="props.visible" backdrop-dismiss :presenting-element="props.presentingElement"
@will-present="onOpen()"
@did-dismiss="onClose()">
<ion-header>
<ion-toolbar>
<ion-title>
选项
</ion-title>
<ion-buttons slot="end">
<ion-button @click="closeModal()"></ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content color="light" class="ion-padding">
<ion-list :inset="true" lines="full" class="with-help-block">
<ion-item>
<ion-toggle v-model="settingsStore.collectionMode" color="success">
在设定集中提问
</ion-toggle>
</ion-item>
</ion-list>
<div class="help-block list-help-block">开启此功能后将会在整个设定集中抓取设定</div>
<ion-list :inset="true" lines="full" class="with-help-block">
<ion-item>
<ion-select label="设定抓取数量" v-model="settingsStore.docExtractLimit">
<ion-select-option :value="0">不抓取设定</ion-select-option>
<ion-select-option :value="1">1</ion-select-option>
<ion-select-option :value="3">3</ion-select-option>
<ion-select-option :value="5">5</ion-select-option>
<ion-select-option :value="10">10</ion-select-option>
<ion-select-option :value="15">15</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<div class="help-block list-help-block">更多的抓取数量也许能够提升回答精确度但是每次提问需要更多积分推荐使用5或10</div>
<ion-list :inset="true" lines="full">
<ion-item button :detail="false" @click="onManualUpdateIndex">
<ion-label color="primary">
更新页面索引
</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>

@ -5,13 +5,39 @@ export default {
</script>
<script lang="ts" setup>
import { createOutline, reloadOutline, gitNetworkOutline } from 'ionicons/icons'
const props = defineProps<{
sender: string,
avatar: string,
content: string,
time: string,
assistant?: boolean
time?: number,
assistant?: boolean,
cursor?: boolean,
edit?: boolean,
change?: boolean
}>()
const emit = defineEmits<{
(name: 'clickEdit'): void,
(name: 'clickChange'): void,
(name: 'clickCreateBranch'): void
}>()
const displayTime = computed(() => {
if (props.time) {
const date = new Date(props.time * 1000)
let year = date.getFullYear().toString().padStart(4, '0')
let month = (date.getMonth() + 1).toString().padStart(2, '0')
let day = date.getDate().toString().padStart(2, '0')
let hour = date.getHours().toString().padStart(2, '0')
let minute = date.getMinutes().toString().padStart(2, '0')
let second = date.getSeconds().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
} else {
return ''
}
})
</script>
<template>
@ -26,11 +52,25 @@ const props = defineProps<{
<div class="message-header">
<div class="header-left">
<span class="message-sender">{{ props.sender }}</span>
<span class="message-time">{{ props.time }}</span>
<span class="message-time">{{ displayTime }}</span>
</div>
<div class="header-right">
<ion-button fill="clear" size="small" color="primary" v-if="props.edit" @click="emit('clickEdit')">
<ion-icon slot="start" :icon="createOutline"></ion-icon>
<ion-label>编辑</ion-label>
</ion-button>
<ion-button fill="clear" size="small" color="primary" v-if="props.change" @click="emit('clickChange')">
<ion-icon slot="start" :icon="reloadOutline"></ion-icon>
<ion-label>换一个</ion-label>
</ion-button>
<ion-button fill="clear" size="small" color="primary" v-if="props.assistant" @click="emit('clickCreateBranch')">
<ion-icon slot="start" :icon="gitNetworkOutline"></ion-icon>
<ion-label>创建分支</ion-label>
</ion-button>
</div>
</div>
<div class="message-body">
<markdown-parser :md="props.content"></markdown-parser>
<markdown-parser :md="props.content" :cursor="props.cursor"></markdown-parser>
</div>
</div>
<div class="avatar-fill"></div>
@ -42,15 +82,12 @@ const props = defineProps<{
@import '@/theme/content-container.scss';
.message-container {
border-top: 1px solid rgba(var(--ion-text-color-rgb), 0.1);
width: 100%;
&:first-of-type {
border-top: none;
}
&.assistant {
background: var(--ion-color-light-tint);
background: #f8f8f8;
@media (prefers-color-scheme: dark) {
background: #101010;
}
}
.message {
@ -70,7 +107,7 @@ const props = defineProps<{
flex-basis: 0;
}
@media screen and (min-width: 768px) {
@media (min-width: 768px) {
.avatar-fill {
width: 48px;
flex-basis: 48px;
@ -87,6 +124,12 @@ const props = defineProps<{
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 767px) {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
.message-sender {

@ -12,7 +12,7 @@ const props = defineProps<{
<template>
<div class="loading-title-container">
<div class="loading-title-title">
<div class="loading-title-title" @click="">
<slot></slot>
</div>
<div class="loading-title-loading-spinner" :class="{ 'active': props.loading }">
@ -31,6 +31,12 @@ $spinner-width: 44px;
width: 100%;
}
.loading-title-title {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-title-loading-spinner {
overflow: hidden;
opacity: 0;

@ -8,17 +8,26 @@ export default {
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'github-markdown-css/github-markdown.css'
import { move } from 'ionicons/icons';
const props = defineProps<{
md: string
md: string,
cursor?: boolean
}>()
const outputRef = ref<HTMLDivElement>()
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'DIV']
let currentChunkDom: HTMLDivElement | null = null
let buffer = '';
let inCodeBlock = false;
let cursorDom = document.createElement('span')
cursorDom.classList.add('text-output-cursor')
cursorDom.innerText = '\u2589'
cursorDom.style.display = 'none'
const md = new MarkdownIt({
html: false,
breaks: true,
@ -34,6 +43,21 @@ const md = new MarkdownIt({
}
})
const moveCursor = () => {
if (outputRef.value) {
//
let lastMarkdownChild = outputRef.value.lastElementChild?.lastElementChild
if (!lastMarkdownChild) {
lastMarkdownChild = outputRef.value.lastElementChild
}
if (lastMarkdownChild && textTags.includes(lastMarkdownChild.tagName)) {
lastMarkdownChild.appendChild(cursorDom)
} else {
outputRef.value.appendChild(cursorDom)
}
}
}
onMounted(() => {
nextTick(() => {
if (outputRef.value) {
@ -50,6 +74,8 @@ onMounted(() => {
} else {
buffer = ''
}
moveCursor()
}
})
})
@ -102,7 +128,7 @@ watch(() => props.md, (newVal, oldVal) => {
chunkDom.innerHTML = md.render(newVal)
outputRef.value.appendChild(chunkDom)
currentChunkDom = chunkDom
let lastNewBlock = newVal.lastIndexOf('\n\n')
if (lastNewBlock !== -1) {
buffer = newVal.substring(lastNewBlock + 2)
@ -110,24 +136,54 @@ watch(() => props.md, (newVal, oldVal) => {
buffer = ''
}
}
moveCursor()
}
})
watch(() => props.cursor, (cursorEnabled) => {
if (cursorEnabled) {
cursorDom.style.display = 'inline'
} else {
cursorDom.style.display = 'none'
}
}, { immediate: true })
</script>
<template>
<div class="markdown-body" ref="outputRef"></div>
<div class="markdown-body text-selectable" ref="outputRef"></div>
</template>
<style lang="scss" scoped>
.markdown-body {
--color-canvas-default: transparent;
:deep(> *:last-child) > *:last-child {
:deep(> *:last-child)>*:last-child {
margin-bottom: 0;
}
:deep(> *:first-child) > *:first-child {
:deep(> *:first-child)>*:first-child {
margin-top: 0;
}
}
:deep(.text-output-cursor) {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
animation: blink 1.2s step-end infinite;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

@ -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
}
}

2
src/global.d.ts vendored

@ -2,4 +2,4 @@ declare global {
interface Window {
_loggedIn?: boolean
}
}
}

@ -0,0 +1,655 @@
<script lang="ts">
export default {
name: 'ToolboxLayout'
}
</script>
<script lang="ts" setup>
import {
add,
sparkles
} from 'ionicons/icons'
import router from '@/router'
import { useConversationStore, usePageStore, useUserStore } from '@/stores'
import { ConversationInfo, TypeaheadItem } from '@/types/base'
import { useRoute } from 'vue-router'
import {
chatboxEllipsesOutline,
heartOutline,
heart,
trashOutline,
pencilOutline,
ellipsisHorizontalCircleOutline,
chevronDown,
} from 'ionicons/icons'
import { showConfirm, showError, showRenameDialog } from '@/utils/dialog'
import { loadingController, toastController } from '@ionic/vue'
import { pinConversation } from '@/utils/actions'
import { moduleConversationRoute } from '@/types/enum'
import { IonRouterOutletInstance } from '@/types/instance'
import { getRecommandTitles } from '@/utils/store'
import { api } from '@/api'
const route = useRoute()
const pageStore = usePageStore()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const state = reactive({
storeLoaded: false,
errorMessage: null as string | null,
editingMode: false,
selectedConversationIds: [] as number[],
showMenu: false,
showMenuTriggerEvent: null as Event | null,
showMenuPosition: 'trigger' as 'trigger' | 'event',
showMenuConversation: null as ConversationInfo | null
})
const selectPageModalState = reactive({
visible: false
})
const mainRouterOutlet = inject<Ref<IonRouterOutletInstance>>('mainRouterOutlet')
const getModuleIcon = (moduleName: string) => {
switch (moduleName) {
case 'chatcomplete':
return chatboxEllipsesOutline
default:
return null
}
}
const menuItemList = computed(() => {
return conversationStore.list.map((p: ConversationInfo) => {
return {
...p,
icon: getModuleIcon(p.module),
}
})
})
//
const startEditing = () => {
state.selectedConversationIds = []
state.editingMode = true
}
const exitEditing = () => {
state.editingMode = false
state.selectedConversationIds = []
}
const showMenu = (event: Event, conversationInfo: ConversationInfo, pos: 'trigger' | 'event') => {
state.showMenu = true
state.showMenuTriggerEvent = event
state.showMenuConversation = conversationInfo
state.showMenuPosition = pos
}
const onEditToggleSelectConversation = (conversationId: number) => {
console.log(conversationId)
if (state.selectedConversationIds.includes(conversationId)) {
state.selectedConversationIds = state.selectedConversationIds.filter(p => p !== conversationId)
} else {
state.selectedConversationIds.push(conversationId)
}
}
const deleteConversation = async (conversationId: number) => {
if (await showConfirm('你确定要删除这个对话吗?', {
title: '删除对话', danger: true
})) {
let loading = await loadingController.create({
message: '正在删除对话...',
translucent: true,
backdropDismiss: false
})
await loading.present()
try {
await conversationStore.remove(conversationId)
//
if (conversationStore.currentId === -1) {
backToToolboxIndex()
}
} catch (err) {
console.error('删除对话错误', err)
} finally {
await loading.dismiss()
}
}
}
const renameConversation = async (conversationId: number) => {
let conversationInfo = conversationStore.list.find(p => p.id === conversationId)
if (!conversationInfo) {
return
}
let newName = await showRenameDialog(conversationInfo.title ?? '')
if (newName) {
let loading = await loadingController.create({
message: '正在更改对话标题...',
translucent: true,
backdropDismiss: false
})
await loading.present()
try {
await conversationStore.editTitle(conversationId, newName)
} catch (err) {
console.error('更改对话标题错误', err)
} finally {
await loading.dismiss()
}
}
}
const editingSelectAll = () => {
if (state.selectedConversationIds.length === menuItemList.value.length) {
state.selectedConversationIds = []
} else {
state.selectedConversationIds = menuItemList.value.map(p => p.id)
}
}
const batchDelete = async () => {
if (state.selectedConversationIds.length === 0) {
return
}
if (await showConfirm(`你确定要删除这 ${state.selectedConversationIds.length} 个对话吗?`, {
title: '删除对话',
danger: true
})) {
let loading = await loadingController.create({
message: '正在删除对话...',
translucent: true,
backdropDismiss: false
})
await loading.present()
try {
await conversationStore.remove(state.selectedConversationIds)
//
if (conversationStore.currentId === -1) {
backToToolboxIndex()
}
} catch (err) {
console.error('删除对话错误', err)
} finally {
await loading.dismiss()
}
state.selectedConversationIds = []
exitEditing()
}
}
//
const backToToolboxIndex = () => {
router.replace({
name: 'toolbox_index'
})
conversationStore.currentId = -1
}
const setCurrentTitle = async (title?: string) => {
if (title) {
pageStore.title = title
pageStore.load().catch(err => {
console.error('加载页面错误', err)
showError('加载页面信息错误:' + err.message)
})
}
}
const onSelectConversation = async (conversationInfo: any) => {
conversationStore.currentId = conversationInfo.id
const selectedConversation = conversationStore.current!
const moduleRouteName = moduleConversationRoute[selectedConversation.module]
if (moduleRouteName) {
setCurrentTitle(selectedConversation.page_title)
router.replace({
name: moduleRouteName,
params: {
id: selectedConversation.id
}
})
} else {
const toast = await toastController.create({
message: '不支持的工具类型:' + selectedConversation.module,
duration: 5000
})
await toast.present()
}
}
//
const onClickSelectTitle = () => {
selectPageModalState.visible = true
}
const loadTitleItems = async (kw: string | undefined): Promise<TypeaheadItem[]> => {
let titles: string[]
if (kw) {
titles = await api.base.searchTitle({ kw })
} else {
titles = getRecommandTitles()
}
return titles.map((title) => ({
key: title,
value: title,
title: title
}))
}
const onSelectedTitle = async (title: string) => {
setCurrentTitle(title)
pageStore.title = title
selectPageModalState.visible = false
backToToolboxIndex()
}
const pointsText = computed(() => {
const points = userStore.points ?? 0
if (points === -2) {
return '加载中'
} else if (points === -1) {
return '∞'
} else {
return points
}
})
const initStore = async () => {
try {
if (typeof route.query.title === 'string') {
pageStore.title = route.query.title
await pageStore.load();
state.storeLoaded = true
setCurrentTitle(pageStore.title)
} else {
state.storeLoaded = true
setCurrentTitle('首页')
}
await conversationStore.load();
} catch (err: any) {
state.errorMessage = err.message
}
}
onMounted(async () => {
await initStore()
//
if (route.params.id) {
const conversationId = parseInt(route.params.id as string)
conversationStore.currentId = conversationId
if (conversationStore.current) {
setCurrentTitle(conversationStore.current.page_title)
}
}
})
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="color-force-light-dark" color="dark">
<ion-buttons slot="start">
<ion-chip color="light" class="points-left" v-if="userStore.loggedIn">
<ion-icon :icon="sparkles" title="星屑"></ion-icon>
<ion-label color="light">{{ pointsText }}</ion-label>
</ion-chip>
</ion-buttons>
<ion-title>
<ion-button fill="clear" color="light" @click="onClickSelectTitle">
{{ pageStore.displayTitle || pageStore.title || '未选择页面' }}
<ion-icon :icon="chevronDown" slot="end"></ion-icon>
</ion-button>
</ion-title>
<ion-buttons slot="end">
<ion-chip color="light" class="points-right" v-if="userStore.loggedIn">
<ion-icon :icon="sparkles" title="星屑"></ion-icon>
<ion-label color="light">{{ pointsText }}</ion-label>
</ion-chip>
<ion-avatar class="header-avatar">
<img :src="userStore.displayAvatar" :title="userStore.displayName" :alt="userStore.displayName" />
</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="conversationStore.isLoaded">
<ion-buttons slot="start">
<ion-button title="编辑" v-if="!state.editingMode" @click="startEditing">
编辑
</ion-button>
<ion-button title="退出编辑" v-if="state.editingMode" @click="exitEditing">
取消
</ion-button>
</ion-buttons>
<ion-title>
<loading-title :loading="!state.storeLoaded">
对话列表
</loading-title>
</ion-title>
<ion-buttons slot="primary">
<ion-button title="新对话" @click="backToToolboxIndex">
<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="pageStore.title">
<ion-list-header v-if="menuItemList.length === 0">
<ion-label class="conversation-list-title">这里还什么都没有呢</ion-label>
</ion-list-header>
<ion-item-sliding v-for="(p, i) in menuItemList" :key="p.id" :disabled="state.editingMode">
<ion-item-options side="start">
<ion-item-option v-if="!p.pinned" color="secondary" aria-label=""
@click="pinConversation(p.id, true)">
<ion-icon slot="icon-only" :icon="heartOutline"></ion-icon>
</ion-item-option>
<ion-item-option v-else color="secondary" aria-label="" @click="pinConversation(p.id, false)">
<ion-icon slot="icon-only" :icon="heart"></ion-icon>
</ion-item-option>
</ion-item-options>
<ion-item @click.prevent button :detail="false" lines="full" class="hydrated"
:class="{ selected: p.id === conversationStore.currentId }"
@contextmenu.prevent="showMenu($event, p, 'event')">
<ion-checkbox slot="start" v-if="state.editingMode"
:checked="state.selectedConversationIds.includes(p.id)" @click="onEditToggleSelectConversation(p.id)"
aria-label="选择编辑项目"></ion-checkbox>
<ion-menu-toggle :auto-hide="false">
<ion-label @click="onSelectConversation(p)">
<p class="conversation-page-title">{{ p.page_title || '' }}</p>
<div class="conversation-list-item-header">
<ion-icon v-if="p.icon" :icon="p.icon" size="small"></ion-icon>
<h2>{{ p.title || '无标题' }}</h2>
</div>
<p>{{ p.description || ' ' }}</p>
</ion-label>
</ion-menu-toggle>
<ion-button slot="end" fill="clear" title="选项" v-if="!state.editingMode"
@click="showMenu($event, p, 'trigger')">
<ion-icon slot="icon-only" :color="p.id === conversationStore.currentId ? 'light' : 'primary'"
:icon="ellipsisHorizontalCircleOutline"></ion-icon>
</ion-button>
</ion-item>
<ion-item-options side="end" @ion-swipe="deleteConversation(p.id)">
<ion-item-option color="success" aria-label="" @click="renameConversation(p.id)">
<ion-icon slot="start" :icon="pencilOutline"></ion-icon>
<ion-label>更改标题</ion-label>
</ion-item-option>
<ion-item-option color="danger" aria-label="" expandable @click="deleteConversation(p.id)">
<ion-icon slot="icon-only" :icon="trashOutline"></ion-icon>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-popover dismiss-on-select :is-open="state.showMenu" :event="state.showMenuTriggerEvent"
:reference="state.showMenuPosition" @didDismiss="state.showMenu = false">
<ion-content>
<ion-list>
<ion-item button :detail="false" v-if="!state.showMenuConversation?.pinned"
@click="pinConversation(state.showMenuConversation!.id, true)">
<ion-icon slot="start" :icon="heartOutline"></ion-icon>
<ion-label>收藏</ion-label>
</ion-item>
<ion-item button :detail="false" v-else @click="pinConversation(state.showMenuConversation!.id, false)">
<ion-icon slot="start" :icon="heart"></ion-icon>
<ion-label>取消收藏</ion-label>
</ion-item>
<ion-item button :detail="false" @click="renameConversation(state.showMenuConversation!.id)">
<ion-icon slot="start" :icon="pencilOutline"></ion-icon>
<ion-label>更改标题</ion-label>
</ion-item>
<ion-item button :detail="false" @click="deleteConversation(state.showMenuConversation!.id)">
<ion-icon slot="start" color="danger" :icon="trashOutline"></ion-icon>
<ion-label color="danger">删除</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-popover>
</ion-content>
<ion-footer :translucent="true" v-if="state.editingMode">
<ion-toolbar>
<ion-buttons slot="start">
<ion-button v-if="state.selectedConversationIds.length !== menuItemList.length" title="选择全部" fill="clear"
@click="editingSelectAll">
选择全部
</ion-button>
<ion-button v-else title="取消选择全部" fill="clear" @click="editingSelectAll">
取消选择
</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button title="删除" color="danger" fill="clear" @click="batchDelete">
<ion-icon slot="start" :ios="trashOutline" :md="trashOutline"></ion-icon>
删除
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ion-menu>
<div id="main-content">
<transition-group name="fade">
<ion-router-outlet v-if="state.storeLoaded"></ion-router-outlet>
<div class="status-container" v-else>
<div v-if="state.errorMessage">
<strong>提示</strong>
<p>{{ state.errorMessage }}</p>
</div>
<ion-spinner v-else></ion-spinner>
</div>
</transition-group>
</div>
</ion-split-pane>
<typeahead-modal v-model:visible="selectPageModalState.visible" title="切换页面" :load-items="loadTitleItems"
@select="onSelectedTitle" :presenting-element="mainRouterOutlet?.$el"></typeahead-modal>
</ion-content>
</ion-page>
</template>
<style lang="scss" scoped>
@import '@/theme/status-container.scss';
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;
--padding-top: 6px;
--padding-bottom: 6px;
--min-height: 50px;
--inner-padding-end: 0;
ion-label {
display: flex;
flex-direction: column;
gap: 4px;
}
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;
}
}
}
.conversation-list-item-header {
width: 100%;
display: flex;
align-items: center;
gap: 4px;
>h2 {
flex: 1 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
>ion-icon {
flex: 0 0 24px;
}
}
.conversation-page-title {
color: var(--ion-color-step-600, #666666);
font-weight: 700;
letter-spacing: 0.4px;
}
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-page-title {
color: var(--ion-color-primary-contrast);
}
}
.conversation-list-title {
margin-top: 20px;
font-size: 1.2rem;
}
.header-avatar {
width: 36px;
height: 36px;
}
//
.points-left {
display: none;
}
@media (max-width: 767px) {
.points-left {
display: block;
}
.points-right {
display: none;
}
}
#main-content {
flex: 1 1;
}
</style>

@ -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>

@ -23,10 +23,19 @@ import '@ionic/vue/css/display.css'
/* Theme variables */
import './theme/variables.css'
import './theme/ionic-override.scss'
import { store, storeKey } from './stores'
import './theme/styles.scss'
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'
import { piniaInitLoader } from './utils/store'
const pinia = createPinia()
pinia.use(piniaInitLoader)
pinia.use(piniaPersist)
const app = createApp(App)
.use(store, storeKey)
.use(pinia)
.use(IonicVue, {
mode: 'ios'
})

7
src/pinia.d.ts vendored

@ -0,0 +1,7 @@
import 'pinia'
declare module 'pinia' {
export interface PiniaCustomProperties {
with(): Promise<void>
}
}

@ -1,3 +1,4 @@
import { useUserStore } from '@/stores';
import { showError } from '@/utils/dialog';
import { ServerError } from '@/utils/error';
import axios, { AxiosResponse } from 'axios';
@ -11,7 +12,8 @@ export const toolkitApi = axios.create({
})
toolkitApi.interceptors.request.use((config) => {
const token = sessionStorage.getItem('aiassistant_token')
const userStore = useUserStore()
const token = userStore.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@ -30,20 +32,29 @@ toolkitApi.interceptors.response.use((response) => {
}, async (error) => {
if (error.response) {
let response: AxiosResponse = error.response
const userStore = useUserStore()
// 处理登录相关错误
if (response.status === 401) {
if ((window as any)._loggedIn) {
if (userStore.loggedIn) {
showError('登录信息不存在,请通过百科页面上的链接重新进入此页面。')
} else {
throw new ServerError(response.data.error ?? error.message, response)
throw new ServerError(response.data.error?.message ?? error.message, response, response.data.error)
}
}
// 处理其他服务器报告的错误
if (response.data && 'status' in response.data) {
if (response.data?.status) {
if (response.data.status < 0 && response.data.error) {
showError(response.data.error)
throw new ServerError(response.data.error, response)
showError(response.data.error?.message ?? '服务器错误');
throw new ServerError(response.data.error?.message, response, response.data.error)
}
} else if (response.status >= 500) {
showError(`服务器错误:${response.status} ${response.statusText}`)
throw new ServerError(error.message, response, error)
} else if (response.status >= 400) {
showError(`服务器返回错误:${response.status} ${response.statusText}`)
throw new ServerError(error.message, response, error)
} else if (response.headers['Content-Type'] !== "application/json") {
showError('服务器返回数据格式错误。')
}
}
showError('无法连接到服务器:' + error.message)

@ -1,25 +1,34 @@
import { createRouter, createWebHistory } from '@ionic/vue-router';
import { RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHistory } from '@ionic/vue-router'
import { RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
{
path: '',
component: () => import('@/views/Index.vue')
// component: () => import('@/views/Index.vue')
redirect: '/toolbox'
},
// {
// path: '/login',
// component: () => import('@/views/Login.vue')
// },
{
path: '/login',
component: () => import('@/views/Login.vue')
},
{
path: '/toolkit/:title',
component: () => import('@/layouts/ToolkitLayout.vue'),
path: '/toolbox',
component: () => import('@/layouts/ToolboxLayout.vue'),
name: 'toolbox',
children: [
{
path: '',
component: () => import('@/views/ToolkitIndex.vue')
name: 'toolbox_index',
component: () => import('@/views/ToolboxIndex.vue')
},
{
path: 'chatcomplete/:id',
name: 'toolbox_chatcomplete',
component: () => import('@/views/ChatComplete.vue')
},
{
path: 'chatcomplete',
name: 'toolbox_chatcomplete_new',
component: () => import('@/views/ChatComplete.vue')
}
]

@ -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';
},
});

@ -8,4 +8,34 @@ ion-toolbar {
--padding-start: 15px;
--padding-end: 15px
}
}
ion-item {
--background-hover-opacity: .02;
}
.header-translucent-ios ion-toolbar,
.footer-translucent-ios ion-toolbar {
--opacity: 1 !important;
}
ion-button.color-force-light,
ion-label.color-force-light {
--color: var(--ion-color-force-light, #fff);
}
.color-force-light-dark {
--ion-color-dark: #222428;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
--ion-color-light: #f4f5f8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
}

@ -1,4 +1,4 @@
#status-container {
.status-container {
text-align: center;
position: absolute;
left: 0;

@ -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;
}

@ -65,6 +65,14 @@ http://ionicframework.com/docs/theming/ */
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
/** force dark **/
--ion-color-force-dark: #222428;
--ion-color-force-dark-rgb: 34, 36, 40;
--ion-color-force-dark-contrast: #ffffff;
--ion-color-force-dark-contrast-rgb: 255, 255, 255;
--ion-color-force-dark-shade: #1e2023;
--ion-color-force-dark-tint: #383a3e;
/** medium **/
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
@ -80,6 +88,14 @@ http://ionicframework.com/docs/theming/ */
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
/** force light **/
--ion-color-force-light: #f4f5f8;
--ion-color-force-light-rgb: 244, 245, 248;
--ion-color-force-light-contrast: #000000;
--ion-color-force-light-contrast-rgb: 0, 0, 0;
--ion-color-force-light-shade: #d7d8da;
--ion-color-force-light-tint: #f5f6f9;
}
@media (prefers-color-scheme: dark) {

@ -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,4 +1,4 @@
import { alertController } from "@ionic/vue"
import { alertController, toastController } from "@ionic/vue"
export async function showError(message: string, title?: string) {
const alert = await alertController.create({
@ -8,4 +8,93 @@ export async function showError(message: string, title?: string) {
})
await alert.present()
}
export type ShowConfirmOptions = {
title?: string,
danger?: boolean,
}
export function showConfirm(message: string, options: ShowConfirmOptions = {}): Promise<boolean> {
return new Promise(async (resolve, reject) => {
const alert = await alertController.create({
header: options.title ?? '提示',
message: message,
translucent: true,
buttons: [
{
text: '取消',
role: 'cancel',
handler: () => {
resolve(false)
}
},
{
text: '确定',
role: options.danger ? 'destructive' : 'confirm',
handler: () => {
resolve(true)
}
}
],
})
await alert.present()
await alert.onDidDismiss()
resolve(false)
})
}
export function showRenameDialog(oldName: string): Promise<string | undefined> {
return new Promise(async (resolve, reject) => {
const alert = await alertController.create({
header: '重命名',
translucent: true,
inputs: [
{
name: 'name',
type: 'text',
value: oldName,
placeholder: '请输入新的名称'
}
],
buttons: [
{
text: '取消',
role: 'cancel',
handler: () => {
resolve(undefined)
}
},
{
text: '确定',
role: 'confirm',
handler: (data) => {
resolve(data.name)
}
}
],
})
await alert.present()
await alert.onDidDismiss()
resolve(undefined)
})
}
export async function showHelpToast(message: string) {
const toast = await toastController.create({
message,
duration: 10000,
position: 'top',
translucent: true,
buttons: [
{
text: '关闭',
role: 'cancel',
}
]
})
await toast.present()
}

@ -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,103 +1,719 @@
<script setup lang="ts">
import router from '@/router';
import { useStore } from '@/stores';
import { ellipsisHorizontalCircle, paperPlane, pencil } from 'ionicons/icons'
import { useConversationStore, useGuideStore, usePageStore, useSettingsStore, useUserStore } from '@/stores'
import { createOutline, 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 } 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'
export type ConversationChunkInfo = {
id: number,
message_data: ChatCompleteChunkInfo,
}
const MODULE_NAME = 'chatcomplete'
const route = useRoute()
const router = useIonRouter()
const userStore = useUserStore()
const pageStore = usePageStore()
const conversationStore = useConversationStore()
const guideStore = useGuideStore()
const settingsStore = useSettingsStore()
const store = useStore()
const mainRouterOutlet = inject<Ref<IonRouterOutletInstance>>('mainRouterOutlet')
const state = reactive({
conversationLoading: false,
errorMessage: null as string | null,
conversationTitle: '加载中...',
conversationId: 0,
pinned: false,
conversationChunkIdList: [] as number[],
messageChunkList: [] as ConversationChunkInfo[],
loadedChunkSeek: 0,
lastUserMsgId: [] as [number, string] | [],
lastAssistantMsgId: [] as [number, string] | [],
editingId: null as [number, string] | null,
formMessage: '',
markdownOutput: '# 测试',
conversationLoading: true
pointCost: '0',
pointCostLoading: false,
settingsModalOpened: false
})
const ascMessageChunkList = computed(() => state.messageChunkList.slice().reverse())
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 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 = () => {
console.log('onSendMessage')
state.formMessage = ''
}
const onSendMessage = async () => {
if (state.formMessage === '') return
//
let contentEl = contentRef.value?.$el
contentEl?.scrollToBottom()
ignoreAutoScroll = false
state.conversationLoading = true
try {
let question = state.formMessage
const startRes = await api.chat.startChatComplete(pageStore.title, question, {
conversationId: state.conversationId,
editMessageId: state.editingId || undefined,
inCollection: settingsStore.collectionMode,
extractLimit: settingsStore.docExtractLimit
})
state.formMessage = ''
await editingToast?.dismiss()
let latestChunk: ConversationChunkInfo
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)
}
}
onMounted(() => {
const testMarkdown = `---
__Advertisement :)__
startAutoScroll() //
- __[pica](https://nodeca.github.io/pica/demo/)__ - high quality and fast image
resize in browser.
- __[babelfish](https://github.com/nodeca/babelfish/)__ - developer friendly
i18n with plurals support and easy syntax.
latestChunk.message_data.push({
role: 'user',
content: question,
time: Date.now(),
tokens: 0
})
You will like those projects!
latestChunk.message_data.push({
role: 'assistant',
content: '',
tokens: 0,
streaming: true
})
---
let userMessage = latestChunk.message_data[latestChunk.message_data.length - 2]
let assistantMessage = latestChunk.message_data[latestChunk.message_data.length - 1]
# h1 Heading 8-)
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 Heading
const chatCompleteRes = await api.chat.chatCompleteStreamOutput(startRes.task_id, (message) => {
assistantMessage.content += message
})
assistantMessage.streaming = false
## Horizontal Rules
userMessage.id = chatCompleteRes.question_message_id
assistantMessage.id = chatCompleteRes.response_message_id
state.lastUserMsgId = [startRes.chunk_id, chatCompleteRes.question_message_id]
state.lastAssistantMsgId = [startRes.chunk_id, chatCompleteRes.response_message_id]
___
let description = assistantMessage.content.substring(0, 150)
if (description.length < assistantMessage.content.length) {
description += '...'
}
---
if (startRes.conversation_id !== state.conversationId) {
if (state.conversationId === 0) {
//
conversationStore.list.push({
id: startRes.conversation_id,
module: MODULE_NAME,
title: '未命名对话',
pinned: false,
description: description,
updated_at: Math.floor(Date.now() / 1000),
extra: {}
})
setPageTitle('未命名对话')
}
state.conversationId = startRes.conversation_id
let newUrl = route.fullPath.replace(/\/\d+$/, '') + '/' + startRes.conversation_id
history.replaceState({}, '', newUrl)
conversationStore.currentId = startRes.conversation_id
showPinHelp()
} else {
conversationStore.updateById(startRes.conversation_id, {
description,
updated_at: Math.floor(Date.now() / 1000),
})
}
***
if (chatCompleteRes.delta_data.title) {
state.conversationTitle = chatCompleteRes.delta_data.title
conversationStore.updateById(startRes.conversation_id, {
title: chatCompleteRes.delta_data.title
})
}
state.conversationLoading = false
## Typographic replacements
nextTick(() => {
stopAutoScroll()
})
Enable typographer option to see result.
//
let pointCost = chatCompleteRes.point_cost
if (typeof pointCost === 'number') {
if (userStore.points > 0) {
userStore.points = Math.max(0, userStore.points - pointCost)
} else {
userStore.load()
}
}
userStore.refresh()
} catch (err: any) {
state.conversationLoading = false
}
}
(c) (C) (r) (R) (tm) (TM) (p) (P) +-
// ============================================================================
//
// ============================================================================
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
}
}
test.. test... test..... test?..... test!....
// ============================================================================
//
// ============================================================================
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 (state.conversationId === 0) {
//
state.conversationTitle = '未命名对话'
} else {
state.messageChunkList = []
state.errorMessage = 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.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
let latestChunk = state.messageChunkList[0]
latestChunk.message_data.forEach((message) => {
if (message.id) {
if (message.role === 'user') {
state.lastUserMsgId = [latestChunk.id, message.id]
} else {
state.lastAssistantMsgId = [latestChunk.id, message.id]
}
}
})
}
} 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 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 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
}
}
"Smartypants, double quotes" and 'single quotes'
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
}
}
## Emphasis
//
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)
}
}
**This is bold text**
let isUserScroll = false
let scrollDebounceTimer: any = null
__This is bold text__
const onMainContentWheel = (ev: WheelEvent) => {
isUserScroll = true
}
*This is italic text*
const onMainContentTouchStart = (ev: TouchEvent) => {
isUserScroll = true
}
_This is italic text_
const onMainContentTouchEnd = (ev: TouchEvent) => {
scrollDebounceTimer = setTimeout(() => {
updateAutoScrollEnabled()
isUserScroll = false
}, 100)
}
~~Strikethrough~~`;
const onMainContentScroll = (ev: Event) => {
if (!isUserScroll) return
let seek = 0
let timer = setInterval(() => {
if (seek < testMarkdown.length) {
state.markdownOutput = testMarkdown.substring(0, seek)
seek++
ignoreAutoScroll = true
if (scrollDebounceTimer) {
clearTimeout(scrollDebounceTimer)
}
scrollDebounceTimer = setTimeout(() => {
updateAutoScrollEnabled()
isUserScroll = false
}, 100)
}
// ============================================================================
//
// ============================================================================
const updatePointCost = async () => {
if (state.formMessage === '') {
state.pointCost = '0'
state.pointCostLoading = false
return
}
try {
const pointCost = await api.chat.getPointCost({
question: state.formMessage,
extract_limit: settingsStore.docExtractLimit
})
if (pointCost === -1) {
state.pointCost = '???'
} else {
clearInterval(timer)
state.conversationLoading = false
router.replace({
path: route.path,
query: {
...route.query,
id: 123
state.pointCost = pointCost.toString()
}
} catch (err) {
console.error(err)
state.pointCost = '???'
}
state.pointCostLoading = false
}
let inComposition = false
let formMessageInputThrottleTimer: any = null
const onInput = (event: any) => {
state.pointCostLoading = true
if (event.isComposing || inComposition) {
return
}
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
formMessageInputThrottleTimer = setTimeout(() => {
updatePointCost()
}, 300)
}
const onCompositionStart = () => {
inComposition = true
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
}
const onCompositionEnd = () => {
inComposition = false
if (formMessageInputThrottleTimer) {
clearTimeout(formMessageInputThrottleTimer)
}
formMessageInputThrottleTimer = setTimeout(() => {
updatePointCost()
}, 400)
}
// ============================================================================
//
// ============================================================================
const fullTitlePopupState = reactive({
isOpen: false,
event: null as any
})
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()
}
updatePointCost()
}
// ============================================================================
//
// ============================================================================
let mainContentResizeObserver: ResizeObserver | null = null
onMounted(async () => {
await updatePageIndex()
await 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)
if (mainContentResizeObserver) {
mainContentResizeObserver.disconnect()
mainContentResizeObserver = null
}
}, 50)
}
})
watch(() => conversationInfo.value, (currentConversation) => {
if (currentConversation) {
if (currentConversation.id === state.conversationId) {
if (currentConversation.title && currentConversation.title !== state.conversationTitle) {
state.conversationTitle = currentConversation.title
}
if (currentConversation.pinned !== state.pinned) {
state.pinned = currentConversation.pinned
}
}
}
}, { deep: true })
</script>
<template>
@ -107,29 +723,48 @@ _This is italic text_
<ion-buttons slot="start">
<ion-menu-button color="primary"></ion-menu-button>
</ion-buttons>
<ion-title>
<loading-title :loading="state.conversationLoading">
未命名对话
<ion-title @click="showFullTitlePopup">
<loading-title :loading="state.conversationLoading" class="text-selectable">
{{ state.conversationTitle || '无标题' }}
</loading-title>
</ion-title>
<ion-buttons slot="end">
<ion-button color="primary" title="收藏" v-if="!state.pinned"
@click="pinConversation(state.conversationId, true)">
<ion-icon slot="icon-only" :icon="heartOutline"></ion-icon>
</ion-button>
<ion-button color="primary" title="取消收藏" v-else @click="pinConversation(state.conversationId, false)">
<ion-icon slot="icon-only" :icon="heart"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
<ion-popover class="full-title-popover" :is-open="fullTitlePopupState.isOpen" :event="fullTitlePopupState.event"
@did-dismiss="fullTitlePopupState.isOpen = false">
<ion-content class="ion-padding text-selectable">
{{ state.conversationTitle || '无标题' }}
</ion-content>
</ion-popover>
</ion-header>
<ion-content :fullscreen="true">
<div id="message-list">
<chat-message
:sender="store.getters.userDisplayName"
:avatar="store.getters.userDisplayAvatar"
content="测试指令"
time="2021-08-01 12:00:00"
></chat-message>
<chat-message
sender="写作助手"
avatar="/public/favicon.png"
:content="state.markdownOutput"
time="2021-08-01 12:00:00"
assistant
></chat-message>
<ion-content :fullscreen="true" ref="contentRef" @wheel.passive="onMainContentWheel"
@touchstart.passive="onMainContentTouchStart" @touchend.passive="onMainContentTouchEnd">
<ion-infinite-scroll position="top" v-if="state.loadedChunkSeek < state.conversationChunkIdList.length"
@ion-infinite="onInfiniteScrollLoad">
<ion-infinite-scroll-content loadingText="加载中..." loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
<!-- 消息列表 -->
<div class="message-chunk" v-for="chunk in ascMessageChunkList" :key="chunk.id">
<template v-for="message, index in chunk.message_data" :key="index">
<chat-message v-if="message.role == 'user'" :sender="userStore.displayName" :avatar="userStore.displayAvatar"
:content="message.content" :time="message.time"
:edit="chunk.id == state.lastUserMsgId[0] && message.id == state.lastUserMsgId[1]"
@click-edit="onEditMessage(chunk.id, message.id)"></chat-message>
<chat-message v-else-if="message.role == 'assistant'" sender="写作助手" avatar="/images/assistant-avatar.png"
:content="message.content" :time="message.time" assistant :cursor="message.streaming"
:change="chunk.id == state.lastAssistantMsgId[0] && message.id == state.lastAssistantMsgId[1]"
@click-create-branch="onCreateBranch(chunk.id, message.id)"
@click-change="onChangeResponse(chunk.id, message.id)"></chat-message>
</template>
</div>
</ion-content>
@ -137,20 +772,35 @@ _This is italic text_
<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="更多选项">
<ion-button class="message-action-btn" title="选项" @click="openSettingsModal">
<ion-icon :icon="ellipsisHorizontalCircle" slot="icon-only"></ion-icon>
</ion-button>
</ion-buttons>
<ion-textarea class="message-input" aria-label="" :rows="1" :maxlength="256" placeholder="在这里输入问题..."
auto-grow v-model="state.formMessage" @keydown="onMessageInputKeyDown"></ion-textarea>
<ion-chip class="point-cost" color="dark">
<ion-icon :icon="sparkles" color="medium" title="星屑"></ion-icon>
<ion-label color="medium" v-show="!state.pointCostLoading">{{ state.pointCost }}</ion-label>
<ion-spinner v-show="state.pointCostLoading" 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"
:disabled="state.conversationLoading"></ion-textarea>
<ion-button shape="round" class="message-send-btn" title="发送" @click="onSendMessage"
:disabled="state.formMessage.length === 0">
<ion-icon :icon="paperPlane" size="small" slot="start"></ion-icon>
:disabled="state.conversationLoading || state.formMessage.length === 0 || state.pointCostLoading">
<ion-icon :icon="paperPlane" slot="start"></ion-icon>
发送
</ion-button>
<ion-button fill="clear" class="message-send-btn-mobile" title="发送" @click="onSendMessage"
:disabled="state.conversationLoading || state.formMessage.length === 0 || state.pointCostLoading">
<ion-icon :icon="paperPlane" slot="icon-only" size="default"></ion-icon>
</ion-button>
</div>
</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>
</ion-page>
</template>
@ -173,6 +823,23 @@ _This is italic text_
text-decoration: none;
}
.message-chunk {
border-top: 1px solid rgba(var(--ion-text-color-rgb), 0.1);
&:first-of-type {
border-top: none;
}
:deep(.message-container) {
border-top: 1px solid rgba(var(--ion-text-color-rgb), 0.1);
width: 100%;
&:first-of-type {
border-top: none;
}
}
}
.message-input-container {
display: flex;
gap: 4px;
@ -201,7 +868,8 @@ _This is italic text_
.message-action-btn-container {
flex-wrap: wrap;
align-content: start;
align-content: center;
margin-top: 1px;
}
.message-action-btn {
@ -213,4 +881,42 @@ _This is italic text_
height: 35px;
margin: 0 0 0 8px;
}
.message-send-btn-mobile {
height: 35px;
margin: 0 0 0 8px;
display: none;
--padding-start: 4px;
--padding-end: 4px;
}
@media (max-width: 767px) {
.message-send-btn {
display: none;
}
.message-send-btn-mobile {
display: block;
}
}
.point-cost {
flex: 0 0 auto;
min-width: 68px;
margin-top: 2px;
margin-right: 4px;
margin-bottom: 1px;
margin-left: 0;
ion-spinner {
width: 20px;
height: 20px;
}
}
@media (max-width: 767px) {
.full-title-popover {
--width: 80vw;
}
}
</style>

@ -1,10 +1,16 @@
<script lang="ts">
export default {
name: 'IndexPage'
}
</script>
<script lang="ts" setup>
import { useStore } from '@/stores'
import { usePageStore } from '@/stores'
const store = useStore()
const pageStore = usePageStore()
onMounted(() => {
store.commit('setPageTitle', '')
pageStore.title = ''
})
</script>

@ -1,7 +1,10 @@
<script lang="ts" setup>
import { useStore } from '@/stores'
<script lang="ts">
export default {
name: 'LoginPage'
}
</script>
const store = useStore()
<script lang="ts" setup>
</script>
@ -17,7 +20,7 @@ const store = useStore()
</ion-header>
<ion-content :fullscreen="true">
<div id="status-container">
<div class="status-container">
<div class="message-box">
<ion-spinner name="lines-small"></ion-spinner>
<strong>正在登录</strong>

@ -1,12 +1,41 @@
<script lang="ts">
export default {
name: 'ToolboxIndex'
}
</script>
<script lang="ts" setup>
import { useStore } from '@/stores'
import { useRoute } from 'vue-router'
import { usePageStore, useUserStore } from '@/stores';
import { moduleNewConversationRoute } from '@/types/enum';
import { setPageTitle } from '@/utils/other';
import { useIonRouter } from '@ionic/vue';
import { useRoute } from 'vue-router';
const route = useRoute()
const store = useStore()
const router = useIonRouter()
const userStore = useUserStore()
const pageStore = usePageStore()
const modules = ['chatcomplete', 'imagen']
const pageTitle = computed(() => {
return Array.isArray(route.params.title) ? route.params.title[0] : route.params.title
})
const appName = import.meta.env.VITE_APP_NAME
const newConversation = (moduleName: string) => {
const moduleRouteName = moduleNewConversationRoute[moduleName]
if (moduleRouteName) {
router.replace({
name: moduleRouteName
})
}
}
onMounted(() => {
store.commit('setPageTitle', route.params.title)
setPageTitle(pageStore.title)
})
</script>
@ -17,23 +46,28 @@ onMounted(() => {
<ion-buttons slot="start">
<ion-menu-button color="primary"></ion-menu-button>
</ion-buttons>
<ion-title>异世界写作助手</ion-title>
<ion-title>{{ appName }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<div id="container">
<h3 class="capitalize page-title">功能</h3>
<h3 class="capitalize page-title">工具列</h3>
<ion-grid>
<ion-row>
<ion-col size="12" size-md="6" size-xl="4">
<ion-card href="#">
<ion-card href="chatcomplete" :disabled="!userStore.loggedIn || !pageStore.title"
@click.prevent="newConversation('chatcomplete')">
<ion-card-header>
<ion-card-title>对话</ion-card-title>
<ion-card-subtitle>写作助手</ion-card-subtitle>
<ion-card-subtitle>
写作助手
<span v-if="!userStore.loggedIn">()</span>
<span v-else-if="!pageStore.title">(需要选择页面)</span>
</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
与写作助手对话获得更多的灵感吧
<p>与写作助手对话获得更多的灵感吧</p>
</ion-card-content>
</ion-card>
</ion-col>

8
src/vite-env.d.ts vendored

@ -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;
}

@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
@ -12,10 +13,10 @@
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["vite.config.ts", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1 @@
{"version":"5.1.3"}

@ -14,7 +14,7 @@ export default defineConfig({
imports: [
'vue',
],
dts: 'src/auto-imports.d.ts',
dts: './src/auto-imports.d.ts'
}),
Components({
dirs: [
@ -23,7 +23,7 @@ export default defineConfig({
resolvers: [
IonicResolver(),
],
dts: 'src/components.d.ts',
dts: './src/components.d.ts'
}),
],
resolve: {
@ -31,17 +31,28 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'ionicons': ['ionicons'],
'markdown-it': ['markdown-it'],
'highlight-js': ['highlight.js'],
}
},
}
},
server: {
host: '0.0.0.0',
port: env.VITE_PORT as unknown as number,
open: env.VITE_OPEN,
proxy: {
'/api': {
target: 'http://192.168.0.14:8144',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
port: env.VITE_PORT as unknown as number || 5174,
open: env.VITE_OPEN,
proxy: {
'/api/toolkit': {
target: 'http://127.0.0.1:8144/',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/toolkit/, ''),
},
},
}
})

Loading…
Cancel
Save