refactor(web): request hooks

This commit is contained in:
Acbox
2026-02-10 17:37:26 +08:00
parent ae65a61ac0
commit b079fa8de9
21 changed files with 536 additions and 381 deletions
-1
View File
@@ -16,7 +16,6 @@
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"katex": "^0.16.28",
"markstream-vue": "0.0.7-beta.2",
+15 -21
View File
@@ -1,21 +1,17 @@
<template>
<aside class="[&_[data-state=collapsed]_:is(.title-container,.exist-btn)]:hidden">
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex flex-row items-center w-full gap-2 px-3 py-2">
<img
src="/logo.png"
class="size-10"
alt="logo.png"
>
<span class="text-xl font-bold text-gray-500 dark:text-gray-400">
Memoh
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
<SidebarHeader class="group-data-[state=collapsed]:hidden">
<div class="flex items-center gap-2 px-3 py-2">
<img
src="/logo.png"
class="size-8"
alt="logo"
>
<span class="text-xl font-bold text-gray-500 dark:text-gray-400">
Memoh
</span>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
@@ -62,23 +58,21 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
Toggle
Toggle,
} from '@memoh/ui'
import { computed } from 'vue'
import { useRouter,useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useRouter, useRoute } from 'vue-router'
import i18n from '@/i18n'
import { ref } from 'vue'
const router = useRouter()
const route=useRoute()
const route = useRoute()
const { t } = i18n.global
const curSlider = ref()
const curSelectSlide = (cur: string) => computed(() => {
return curSlider.value === cur||new RegExp(`^/main/${cur}$`).test(route.path)
return curSlider.value === cur || new RegExp(`^/main/${cur}$`).test(route.path)
})
const sidebarInfo = computed(() => [
{
@@ -144,9 +144,8 @@ import z from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { ref, inject } from 'vue'
import { useMutation } from '@pinia/colada'
import request from '@/utils/request'
import { useKeyValueTags } from '@/composables/useKeyValueTags'
import { useCreatePlatform } from '@/composables/api/usePlatform'
const configTags = useKeyValueTags()
@@ -158,13 +157,7 @@ const validationSchema = toTypedSchema(z.object({
const form = useForm({ validationSchema })
const { mutate: addFetchPlatform } = useMutation({
mutation: (data: Parameters<(Parameters<typeof form.handleSubmit>)[0]>[0]) => request({
url: '/platform/',
data,
method: 'post',
}),
})
const { mutate: addFetchPlatform } = useCreatePlatform()
const addPlatform = form.handleSubmit(async (value) => {
try {
@@ -153,44 +153,31 @@ import {
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import { type ProviderInfo } from '@memoh/shared'
import { clientType } from '@memoh/shared'
import { useCreateProvider } from '@/composables/api/useProviders'
const open = defineModel<boolean>('open')
const cacheQuery=useQueryCache()
const {mutate:providerFetch,isLoading}=useMutation({
mutation: (data: ProviderInfo) => request({
url: '/providers',
data,
method:'post'
}),
onSettled: () => cacheQuery.invalidateQueries({
key:['provider']
})
})
const { mutate: providerFetch, isLoading } = useCreateProvider()
const providerSchema = toTypedSchema(z.object({
api_key: z.string().min(1),
base_url: z.string().min(1),
client_type: z.string().min(1),
name: z.string().min(1),
metadata: z.object({
additionalProp1:z.object()
})
additionalProp1: z.object({}),
}),
}))
const form = useForm({
validationSchema: providerSchema,
})
const createProvider=form.handleSubmit(async (value) => {
const createProvider = form.handleSubmit(async (value) => {
try {
await providerFetch(value)
open.value=false
open.value = false
} catch {
return
}
@@ -260,10 +260,9 @@ import z from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { ref, inject, watch } from 'vue'
import { useMutation, useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import { type MCPListItem as MCPType } from '@memoh/shared'
import { useKeyValueTags } from '@/composables/useKeyValueTags'
import { useCreateOrUpdateMcp } from '@/composables/api/useMcp'
// ---- Env key:value 转换 ----
const envTags = useKeyValueTags()
@@ -286,15 +285,7 @@ const form = useForm({
})
// ---- API ----
const queryCache = useQueryCache()
const { mutate: fetchMCP } = useMutation({
mutation: (data: Parameters<(Parameters<typeof form.handleSubmit>)[0]>[0]) => request({
url: mcpEditData.value?.id ? `/mcp/${mcpEditData.value.id}` : '/mcp/',
method: mcpEditData.value?.id ? 'put' : 'post',
data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
})
const { mutate: fetchMCP } = useCreateOrUpdateMcp()
// ---- Dialog & 编辑状态 ----
const open = inject('open', ref(false))
@@ -308,7 +299,6 @@ const mcpEditData = inject('mcpEditData', ref<{
watch(open, () => {
if (open.value && mcpEditData.value) {
form.setValues(mcpEditData.value)
// 从对象初始化 env 标签
envTags.initFromObject(mcpEditData.value.config?.env as Record<string, string>)
}
if (!open.value) {
@@ -318,7 +308,7 @@ watch(open, () => {
const createMCP = form.handleSubmit(async (value) => {
try {
await fetchMCP(value)
await fetchMCP({ ...value, id: mcpEditData.value?.id })
open.value = false
} catch {
return
@@ -160,51 +160,35 @@ import { useForm } from 'vee-validate'
import { inject, watch, type Ref, ref } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import request from '@/utils/request'
import { useMutation, useQueryCache } from '@pinia/colada'
import {type ModelInfo } from '@memoh/shared'
import { type ModelInfo } from '@memoh/shared'
import { useCreateModel } from '@/composables/api/useModels'
const formSchema = toTypedSchema(z.object({
'is_multimodal': z.coerce.boolean(),
'model_id': z.string().min(1),
'name': z.string().min(1),
'type': z.string().min(1),
'dimensions':z.coerce.number().min(1)
is_multimodal: z.coerce.boolean(),
model_id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
dimensions: z.coerce.number().min(1),
}))
const form = useForm({
validationSchema: formSchema,
initialValues: {
dimensions:1
}
dimensions: 1,
},
})
const { id } = defineProps<{ id: string }>()
const queryCache = useQueryCache()
type ModelInfoType = Parameters<(Parameters<typeof form.handleSubmit>)[0]>[0]
const { mutate: createModel,isLoading } = useMutation({
mutation: (modelInfo: ModelInfoType & {
dimensions: number,
llm_provider_id: string
}) => request({
url: '/models',
data: {
...modelInfo,
},
method: 'post'
}),
onSettled: () => { open.value = false; queryCache.invalidateQueries({ key: ['model'], exact: true }) }
})
const { mutate: createModel, isLoading } = useCreateModel()
const addModel = form.handleSubmit(async (modelInfo) => {
const addModel = form.handleSubmit(async (modelInfo) => {
try {
await createModel({
...modelInfo,
llm_provider_id: id
...modelInfo,
llm_provider_id: id,
})
open.value=false
open.value = false
} catch {
return
}
@@ -0,0 +1,20 @@
import { fetchApi } from '@/utils/request'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
access_token: string
user_id: string
username: string
}
export async function login(data: LoginRequest): Promise<LoginResponse> {
return fetchApi<LoginResponse>('/auth/login', {
method: 'POST',
body: data,
noAuth: true,
})
}
+103
View File
@@ -0,0 +1,103 @@
import { fetchApi } from '@/utils/request'
// ---- Types ----
export interface Bot {
id: string
name?: string
}
export interface BotsResponse {
items: Bot[]
}
export interface CreateSessionResponse {
session_id: string
}
// ---- Plain async functions (used by chat store) ----
export async function fetchBots(): Promise<Bot[]> {
const res = await fetchApi<BotsResponse>('/bots')
return res.items
}
export async function createSession(botId: string): Promise<string> {
const res = await fetchApi<CreateSessionResponse>(
`/bots/${botId}/web/sessions`,
{ method: 'POST' },
)
return res.session_id
}
export async function sendChatMessage(
botId: string,
sessionId: string,
text: string,
): Promise<void> {
await fetchApi(`/bots/${botId}/web/sessions/${sessionId}/messages`, {
method: 'POST',
body: { text },
})
}
/**
* SSE abort
* reader
*/
export function createStreamConnection(
botId: string,
sessionId: string,
onMessage: (text: string) => void,
): () => void {
const controller = new AbortController()
const token = localStorage.getItem('token') ?? ''
;(async () => {
const resp = await fetch(`/api/bots/${botId}/web/sessions/${sessionId}/stream`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
}).catch(() => null)
if (!resp?.ok || !resp.body) return
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx: number
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
const text = extractTextFromEvent(payload)
if (text) onMessage(text)
}
}
})()
return () => controller.abort()
}
function extractTextFromEvent(payload: string): string | null {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
return null
} catch {
return payload
}
}
@@ -0,0 +1,58 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { type MCPListItem } from '@memoh/shared'
// ---- Types ----
export interface McpListResponse {
items: MCPListItem[]
}
export interface CreateMcpRequest {
name: string
config: Record<string, unknown>
active: boolean
}
export interface UpdateMcpRequest extends CreateMcpRequest {
id?: string
}
// ---- Query: 获取 MCP 列表 ----
export function useMcpList() {
const query = useQuery({
key: ['mcp'],
query: async () => {
const res = await fetchApi<McpListResponse>('/mcp/')
return res.items
},
})
return query
}
// ---- Mutations ----
export function useCreateOrUpdateMcp() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: UpdateMcpRequest) => {
const isEdit = !!data.id
return fetchApi(isEdit ? `/mcp/${data.id}` : '/mcp/', {
method: isEdit ? 'PUT' : 'POST',
body: data,
})
},
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
})
}
export function useDeleteMcp() {
const queryCache = useQueryCache()
return useMutation({
mutation: (id: string) => fetchApi(`/mcp/${id}`, {
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
})
}
@@ -0,0 +1,57 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { type ModelInfo } from '@memoh/shared'
import type { Ref } from 'vue'
// ---- Types ----
export interface CreateModelRequest {
name: string
model_id: string
type: string
dimensions: number
is_multimodal: boolean
llm_provider_id: string
}
// ---- Query: 获取 Provider 下的模型列表 ----
export function useModelList(providerId: Ref<string | undefined>) {
const queryCache = useQueryCache()
const query = useQuery({
key: ['model'],
query: () => fetchApi<ModelInfo[]>(
`/providers/${providerId.value}/models`,
),
})
return {
...query,
/** 当 providerId 变化时手动刷新 */
invalidate: () => queryCache.invalidateQueries({ key: ['model'] }),
}
}
// ---- Mutations ----
export function useCreateModel() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: CreateModelRequest) => fetchApi('/models', {
method: 'POST',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['model'], exact: true }),
})
}
export function useDeleteModel() {
const queryCache = useQueryCache()
return useMutation({
mutation: (modelName: string) => fetchApi(`/models/model/${modelName}`, {
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['model'] }),
})
}
@@ -0,0 +1,38 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
// ---- Types ----
export interface PlatformItem {
name: string
active: boolean
config: Record<string, string>
}
export interface CreatePlatformRequest {
name: string
config: Record<string, unknown>
active: boolean
}
// ---- Query: 获取平台列表 ----
export function usePlatformList() {
return useQuery({
key: ['platform'],
query: () => fetchApi<PlatformItem[]>('/platform/'),
})
}
// ---- Mutations ----
export function useCreatePlatform() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: CreatePlatformRequest) => fetchApi('/platform/', {
method: 'POST',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['platform'] }),
})
}
@@ -0,0 +1,63 @@
import { fetchApi } from '@/utils/request'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { type ProviderInfo } from '@memoh/shared'
import type { Ref } from 'vue'
// ---- Types ----
export type ProviderWithId = ProviderInfo & { id: string }
export interface CreateProviderRequest {
name: string
api_key: string
base_url: string
client_type: string
metadata?: Record<string, unknown>
}
export type UpdateProviderRequest = Partial<CreateProviderRequest>
// ---- Query: 获取 Provider 列表 ----
export function useProviderList(clientType: Ref<string>) {
return useQuery({
key: ['provider'],
query: () => fetchApi<ProviderWithId[]>(
`/providers?client_type=${clientType.value}`,
),
})
}
// ---- Mutations ----
export function useCreateProvider() {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: CreateProviderRequest) => fetchApi('/providers', {
method: 'POST',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
})
}
export function useUpdateProvider(providerId: Ref<string | undefined>) {
const queryCache = useQueryCache()
return useMutation({
mutation: (data: UpdateProviderRequest) => fetchApi(`/providers/${providerId.value}`, {
method: 'PUT',
body: data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
})
}
export function useDeleteProvider(providerId: Ref<string | undefined>) {
const queryCache = useQueryCache()
return useMutation({
mutation: () => fetchApi(`/providers/${providerId.value}`, {
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
})
}
+14 -21
View File
@@ -107,50 +107,43 @@ import { useRouter } from 'vue-router'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import request from '@/utils/request'
import { useUserStore } from '@/store/user'
import { ref } from 'vue'
import { ref } from 'vue'
import { toast } from 'vue-sonner'
const router = useRouter()
import { login as loginApi } from '@/composables/api/useAuth'
const router = useRouter()
const formSchema = toTypedSchema(z.object({
username: z.string().min(1),
password: z.string().min(1),
}))
const form = useForm({
validationSchema: formSchema
validationSchema: formSchema,
})
const { login: LoginHandle } = useUserStore()
const { login: loginHandle } = useUserStore()
const loading = ref(false)
const login = form.handleSubmit(async (values) => {
try {
loading.value = true
const loginState = await request({
url: '/auth/login',
method: 'post',
data: { ...values }
}, false)
const data = loginState?.data
const data = await loginApi(values)
if (data?.access_token && data?.user_id) {
LoginHandle({
id: data?.user_id,
username: data?.username,
loginHandle({
id: data.user_id,
username: data.username,
displayName: '',
role: ''
role: '',
}, data.access_token)
} else {
throw new Error('用户名和密码错误')
throw new Error('登录失败')
}
router.replace({
name: 'Main'
})
} catch (error) {
router.replace({ name: 'Main' })
} catch {
toast.error('用户名或密码错误', {
description: '请重新输入用户名和密码',
})
return error
} finally {
loading.value = false
}
+10 -30
View File
@@ -9,9 +9,7 @@
</template>
<script setup lang="ts">
import { useQuery,useMutation,useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import { h, provide,ref, computed } from 'vue'
import { h, provide, ref, computed } from 'vue'
import DataTable from '@/components/data-table/index.vue'
import CreateMCP from '@/components/create-mcp/index.vue'
import { type ColumnDef } from '@tanstack/vue-table'
@@ -21,30 +19,19 @@ import {
} from '@memoh/ui'
import { type MCPListItem as MCPType } from '@memoh/shared'
import { i18nRef } from '@/i18n'
import { useMcpList, useDeleteMcp } from '@/composables/api/useMcp'
const open = ref(false)
const editMCPData = ref<{
name: string,
config: MCPType['config'],
name: string
config: MCPType['config']
active: boolean
id:string
}|null>(null)
id: string
} | null>(null)
provide('open', open)
provide('mcpEditData',editMCPData)
provide('mcpEditData', editMCPData)
const queryCache=useQueryCache()
const { mutate:DeleteMCP}= useMutation({
mutation: (id:string) => request({
url: `/mcp/${id}`,
method:'DELETE'
}),
onSettled() {
queryCache.invalidateQueries({
key:['mcp']
})
}
})
const { mutate: DeleteMCP } = useDeleteMcp()
const columns:ColumnDef<MCPType>[] = [
{
accessorKey: 'name',
@@ -110,15 +97,8 @@ const columns:ColumnDef<MCPType>[] = [
}
]
const { data: mcpData } = useQuery({
key: ['mcp'],
query: () => request({
url: '/mcp/'
})
})
const { data: mcpData } = useMcpList()
const mcpFormatData = computed(() => {
return mcpData.value?.data?.items??[]
})
const mcpFormatData = computed(() => mcpData.value ?? [])
</script>
@@ -9,34 +9,6 @@
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
:default-value="model.enable_as"
@update:model-value="(value) => $emit('enable', {
as: value === 'empty' ? '' : (value as string),
model_id: model.model_id,
})"
>
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="empty">
No Enable
</SelectItem>
<SelectItem value="chat">
Chat
</SelectItem>
<SelectItem value="embedding">
Embedding
</SelectItem>
<SelectItem value="memery">
Memery
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="outline"
class="cursor-pointer"
@@ -69,23 +41,16 @@ import {
ItemTitle,
Badge,
Button,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { type ModelInfo } from '@memoh/shared'
defineProps<{
model: ModelInfo & { enable_as: string }
model: ModelInfo
deleteLoading: boolean
}>()
defineEmits<{
enable: [payload: { as: string; model_id: string }]
edit: [model: ModelInfo]
delete: [name: string]
}>()
@@ -11,7 +11,7 @@
</section>
<section
v-if="models?.length > 0"
v-if="models && models.length > 0"
class="flex flex-col gap-4"
>
<ModelItem
@@ -19,7 +19,6 @@
:key="model.model_id"
:model="model"
:delete-loading="deleteModelLoading"
@enable="(payload) => $emit('enable', payload)"
@edit="(model) => $emit('edit', model)"
@delete="(name) => $emit('delete', name)"
/>
@@ -56,12 +55,11 @@ import { type ModelInfo } from '@memoh/shared'
defineProps<{
providerId: string | undefined
models: (ModelInfo & { enable_as: string })[] | undefined
models: ModelInfo[] | undefined
deleteModelLoading: boolean
}>()
defineEmits<{
enable: [payload: { as: string; model_id: string }]
edit: [model: ModelInfo]
delete: [name: string]
}>()
+5 -16
View File
@@ -2,7 +2,7 @@
// import type { Payment } from '@/components/columns'
import { computed, ref, provide, watch, reactive } from 'vue'
import modelSetting from './model-setting.vue'
import { useQuery, useQueryCache } from '@pinia/colada'
import { useQueryCache } from '@pinia/colada'
import {
ScrollArea,
Sidebar,
@@ -28,29 +28,18 @@ import {
EmptyMedia,
EmptyTitle,
} from '@memoh/ui'
import request from '@/utils/request'
import { type ProviderInfo } from '@memoh/shared'
import AddProvider from '@/components/add-provider/index.vue'
import { clientType } from '@memoh/shared'
import { useProviderList } from '@/composables/api/useProviders'
const filterProvider = ref('')
const { data: providerData } = useQuery({
key: ['provider'],
query: () => request({
url: `/providers?client_type=${filterProvider.value}`,
}).then(fetchValue => fetchValue.data)
})
const { data: providerData } = useProviderList(filterProvider)
const queryCache = useQueryCache()
watch(filterProvider, () => {
queryCache.invalidateQueries({
key: ['provider']
})
}, {
immediate:true
})
queryCache.invalidateQueries({ key: ['provider'] })
}, { immediate: true })
const curProvider = ref<Partial<ProviderInfo> & { id: string }>()
+17 -59
View File
@@ -21,7 +21,6 @@
:provider-id="curProvider?.id"
:models="modelDataList"
:delete-model-loading="deleteModelLoading"
@enable="enableModel"
@edit="handleEditModel"
@delete="deleteModel"
/>
@@ -32,10 +31,16 @@
import { Separator } from '@memoh/ui'
import ProviderForm from './components/provider-form.vue'
import ModelList from './components/model-list.vue'
import { inject, provide, reactive, ref, toRef, watch } from 'vue'
import { computed, inject, provide, reactive, ref, toRef, watch } from 'vue'
import { type ProviderInfo, type ModelInfo } from '@memoh/shared'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import {
useUpdateProvider,
useDeleteProvider,
} from '@/composables/api/useProviders'
import {
useModelList,
useDeleteModel,
} from '@/composables/api/useModels'
// ---- Model provide CreateModel ----
const openModel = reactive<{
@@ -53,69 +58,22 @@ provide('openModelTitle', toRef(openModel, 'title'))
provide('openModelState', toRef(openModel, 'curState'))
function handleEditModel(model: ModelInfo) {
const copy = { ...model }
if ('enable_as' in copy) {
delete (copy as Record<string, unknown>).enable_as
}
openModel.state = true
openModel.title = 'edit'
openModel.curState = copy
openModel.curState = { ...model }
}
// ---- Provider ----
const curProvider = inject('curProvider', ref<Partial<ProviderInfo & { id: string }>>())
const curProviderId = computed(() => curProvider.value?.id)
// ---- API Mutations ----
const queryCache = useQueryCache()
const { mutate: deleteProvider, isLoading: deleteLoading } = useMutation({
mutation: () => request({
url: `/providers/${curProvider.value?.id}`,
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
})
const { mutate: changeProvider, isLoading: editLoading } = useMutation({
mutation: (data: Record<string, unknown>) => request({
url: `/providers/${curProvider.value?.id}`,
method: 'PUT',
data,
}),
onSettled: () => queryCache.invalidateQueries({ key: ['provider'] }),
})
const { mutate: deleteModel, isLoading: deleteModelLoading } = useMutation({
mutation: (id: string) => request({
url: `/models/model/${id}`,
method: 'DELETE',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['model'] }),
})
const { mutate: enableModel } = useMutation({
mutation: (data: { as: string; model_id: string }) => request({
url: '/models/enable',
data,
method: 'POST',
}),
onSettled: () => queryCache.invalidateQueries({ key: ['model'] }),
})
// ---- Model ----
const { data: modelDataList } = useQuery({
key: ['model'],
query: () => request({
url: `/providers/${curProvider.value?.id}/models`,
}).then((res) =>
res.data.map((model: ModelInfo) => ({
...model,
enable_as: model.enable_as ?? 'empty',
})),
),
})
// ---- API Hooks ----
const { mutate: deleteProvider, isLoading: deleteLoading } = useDeleteProvider(curProviderId)
const { mutate: changeProvider, isLoading: editLoading } = useUpdateProvider(curProviderId)
const { mutate: deleteModel, isLoading: deleteModelLoading } = useDeleteModel()
const { data: modelDataList, invalidate: invalidateModels } = useModelList(curProviderId)
watch(curProvider, () => {
queryCache.invalidateQueries({ key: ['model'] })
invalidateModels()
}, { immediate: true })
</script>
+3 -9
View File
@@ -14,19 +14,13 @@
</template>
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
import request from '@/utils/request'
import { computed, provide, ref } from 'vue'
import { provide, ref } from 'vue'
import AddPlatform from '@/components/add-platform/index.vue'
import PlatformCard from './components/platform-card.vue'
import { usePlatformList } from '@/composables/api/usePlatform'
const open = ref(false)
provide('open', open)
const { data: platformData } = useQuery({
key: ['platform'],
query: () => request({ url: '/platform/' }),
})
const platformList = computed(() => platformData.value?.data ?? [])
const { data: platformList } = usePlatformList()
</script>
+31 -89
View File
@@ -1,21 +1,24 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import type { user, robot } from '@memoh/shared'
import request from '@/utils/request'
import {
fetchBots,
createSession,
sendChatMessage,
createStreamConnection,
} from '@/composables/api/useChat'
export const useChatList= defineStore('chatList', () => {
const chatList = reactive<(((user | robot)))[]>([])
const loading=ref(false)
export const useChatList = defineStore('chatList', () => {
const chatList = reactive<(user | robot)[]>([])
const loading = ref(false)
const botId = ref<string | null>(null)
const sessionId = ref<string | null>(null)
const streamAbort = ref<(() => void) | null>(null)
const add = (chatItem: user | robot) => {
chatList.push(chatItem)
}
const abortStream = ref<(() => void) | null>(null)
const nextId = () => `${Date.now()}-${Math.floor(Math.random() * 1000)}`
const addUserMessage = (text: string) => {
add({
chatList.push({
description: text,
time: new Date(),
action: 'user',
@@ -24,7 +27,7 @@ export const useChatList= defineStore('chatList', () => {
}
const addRobotMessage = (text: string) => {
add({
chatList.push({
description: text,
time: new Date(),
action: 'robot',
@@ -34,86 +37,30 @@ export const useChatList= defineStore('chatList', () => {
})
}
const extractTextFromEvent = (payload: string) => {
try {
const event = JSON.parse(payload)
if (typeof event === 'string') return event
if (typeof event?.text === 'string') return event.text
if (typeof event?.content === 'string') return event.content
if (typeof event?.data === 'string') return event.data
if (typeof event?.data?.text === 'string') return event.data.text
return null
} catch {
return payload
}
}
const startStream = async (bot: string, session: string) => {
if (streamAbort.value) {
streamAbort.value()
streamAbort.value = null
}
const controller = new AbortController()
streamAbort.value = () => controller.abort()
const token = localStorage.getItem('token') ?? ''
const resp = await fetch(`/api/bots/${bot}/web/sessions/${session}/stream`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
signal: controller.signal,
}).catch(() => null)
if (!resp || !resp.ok || !resp.body) {
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') continue
const text = extractTextFromEvent(payload)
if (text) {
addRobotMessage(text)
}
}
}
}
const ensureSession = async () => {
if (botId.value && sessionId.value) return
const bots = await fetchBots()
if (!bots.length) throw new Error('No bots found')
botId.value = botId.value ?? bots[0]!.id
sessionId.value = await createSession(botId.value)
if (botId.value && sessionId.value) {
return
}
const botResp = await request({
url: '/bots',
method: 'GET',
})
const bots = botResp?.data?.items ?? []
if (!bots.length) {
throw new Error('No bots found')
}
botId.value = botId.value ?? bots[0].id
const sessionResp = await request({
url: `/bots/${botId.value}/web/sessions`,
method: 'POST',
})
sessionId.value = sessionResp?.data?.session_id
if (botId.value && sessionId.value) {
void startStream(botId.value, sessionId.value)
// 关闭旧流
abortStream.value?.()
abortStream.value = createStreamConnection(
botId.value,
sessionId.value,
addRobotMessage,
)
}
}
const sendMessage = async (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
loading.value = true
try {
addUserMessage(trimmed)
@@ -121,11 +68,7 @@ export const useChatList= defineStore('chatList', () => {
if (!botId.value || !sessionId.value) {
throw new Error('Session not ready')
}
await request({
url: `/bots/${botId.value}/web/sessions/${sessionId.value}/messages`,
method: 'POST',
data: { text: trimmed },
})
await sendChatMessage(botId.value, sessionId.value, trimmed)
} finally {
loading.value = false
}
@@ -133,8 +76,7 @@ export const useChatList= defineStore('chatList', () => {
return {
chatList,
add,
loading,
sendMessage,
}
})
})
+73 -23
View File
@@ -1,28 +1,78 @@
import axios, { type AxiosRequestConfig } from 'axios'
import router from '@/router'
const axiosInstance = axios.create({
baseURL: '/api',
})
const BASE_URL = '/api'
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
export class ApiError extends Error {
status: number
statusText: string
body?: unknown
constructor(status: number, statusText: string, body?: unknown) {
super(`API Error ${status}: ${statusText}`)
this.name = 'ApiError'
this.status = status
this.statusText = statusText
this.body = body
}
return config
})
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
router.replace({ name: 'Login' })
}
return Promise.reject(error)
},
)
export default function request(config: AxiosRequestConfig) {
return axiosInstance(config)
}
export interface FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
body?: unknown
headers?: Record<string, string>
/** 不附加 Authorization header */
noAuth?: boolean
signal?: AbortSignal
}
/**
* fetch
* JSON token 401
* API JSON .data
*/
export async function fetchApi<T = unknown>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const { method = 'GET', body, headers = {}, noAuth = false, signal } = options
if (!noAuth) {
const token = localStorage.getItem('token')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(`${BASE_URL}${url}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
})
if (response.status === 401) {
router.replace({ name: 'Login' })
throw new ApiError(response.status, response.statusText)
}
if (!response.ok) {
let errorBody: unknown
try {
errorBody = await response.json()
} catch {
// 无法解析
}
throw new ApiError(response.status, response.statusText, errorBody)
}
// 204 No Content 等情况
if (response.status === 204 || response.headers.get('content-length') === '0') {
return undefined as T
}
return response.json() as Promise<T>
}