mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
refactor(web): request hooks
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}>()
|
||||
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user