Merge branch 'feat/chat' into 'main'

feat: Chat

See merge request acbox/memohome!1
This commit is contained in:
acbox
2026-01-29 07:33:44 +00:00
112 changed files with 5901 additions and 241 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# PostgreSQL database connection URL
# Format: postgresql://username:password@host:port/database
# Example: postgresql://postgres:password@localhost:5432/memohome
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
DATABASE_URL=postgresql://postgres:1234@localhost:5432/database_name
# ==================================
+7 -3
View File
@@ -1,9 +1,14 @@
{
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"editor.tabSize": 2,
"[typescript]": {
"editor.tabSize": 2,
"editor.insertSpaces": true
@@ -33,4 +38,3 @@
"editor.insertSpaces": true
}
}
+1 -1
View File
@@ -38,5 +38,5 @@
"@algolia/client-search"
]
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
import { db } from '@memoh/db'
import { users, settings } from '@memoh/db/schema'
import { eq } from 'drizzle-orm'
/**
* 验证用户凭据
* 优先检查是否为 ROOT 用户,否则查询数据库
*/
export const validateUser = async (username: string, password: string) => {
// 检查是否为 ROOT 用户
const rootUser = process.env.ROOT_USER
const rootPassword = process.env.ROOT_USER_PASSWORD
let userId: string | null = null
if (rootUser && rootPassword && username === rootUser) {
if (password === rootPassword) {
// 检查 root 用户是否存在于数据库中
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.username, rootUser))
userId = existingUser?.id
if (!existingUser) {
// 为 root 用户创建数据库记录
// 使用占位符密码哈希,因为实际密码在环境变量中
const [newUser] = await db
.insert(users)
.values({
username: rootUser,
passwordHash: 'ENV_BASED_AUTH', // 占位符,实际使用环境变量验证
role: 'admin',
displayName: 'Root User',
email: null,
avatarUrl: null,
isActive: true,
})
.onConflictDoNothing() // 避免并发创建导致的冲突
.returning({
id: users.id,
})
userId = newUser.id
}
// 检查 root 用户的 settings 是否存在,不存在则创建
const [existingSettings] = await db
.select()
.from(settings)
.where(eq(settings.userId, userId))
if (!existingSettings) {
// 为 root 用户创建默认 settings
await db
.insert(settings)
.values({
userId: userId,
defaultChatModel: null,
defaultEmbeddingModel: null,
defaultSummaryModel: null,
maxContextLoadTime: 60,
language: 'Same as user input',
})
.onConflictDoNothing() // 避免并发创建导致的冲突
}
// 返回 ROOT 用户信息
return {
id: userId,
username: rootUser,
role: 'admin' as const,
displayName: 'Root User',
}
}
return null
}
// 查询数据库中的用户(使用 username 而不是 id
const [user] = await db
.select()
.from(users)
.where(eq(users.username, username))
if (!user) {
return null
}
// 验证密码 (这里使用简单的 Bun.password.verify)
const isValid = await Bun.password.verify(password, user.passwordHash)
if (!isValid) {
return null
}
// 检查账户是否激活
if (!user.isActive) {
return null
}
// 更新最后登录时间
await db
.update(users)
.set({
lastLoginAt: new Date(),
})
.where(eq(users.id, user.id))
return {
id: user.id,
username: user.username,
role: user.role,
displayName: user.displayName || user.username,
email: user.email,
}
}
+182
View File
@@ -0,0 +1,182 @@
import Elysia from 'elysia'
import { adminMiddleware } from '../../middlewares'
import {
GetUserByIdModel,
CreateUserModel,
UpdateUserModel,
DeleteUserModel,
UpdatePasswordModel,
} from './model'
import {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser,
updateUserPassword,
} from './service'
export const userModule = new Elysia({
prefix: '/user',
})
// 使用管理员中间件保护所有路由
.use(adminMiddleware)
// Get all users
.get('/', async ({ query }) => {
try {
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 10
const sortBy = query.sortBy as string || 'createdAt'
const sortOrder = (query.sortOrder as string) || 'desc'
const result = await getUsers({
page,
limit,
sortBy,
sortOrder: sortOrder as 'asc' | 'desc',
})
return {
success: true,
...result,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch users',
}
}
})
// Get user by ID
.get('/:id', async ({ params, set }) => {
try {
const { id } = params
const user = await getUserById(id)
if (!user) {
set.status = 404
return {
success: false,
error: 'User not found',
}
}
return {
success: true,
data: user,
}
} catch (error) {
set.status = 500
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch user',
}
}
}, GetUserByIdModel)
// Create new user
.post('/', async ({ body, set }) => {
try {
const newUser = await createUser(body)
set.status = 201
return {
success: true,
data: newUser,
}
} catch (error) {
if (error instanceof Error && (
error.message.includes('already exists')
)) {
set.status = 409
} else {
set.status = 500
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create user',
}
}
}, CreateUserModel)
// Update user
.put('/:id', async ({ params, body, set }) => {
try {
const { id } = params
const updatedUser = await updateUser(id, body)
if (!updatedUser) {
set.status = 404
return {
success: false,
error: 'User not found',
}
}
return {
success: true,
data: updatedUser,
}
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
set.status = 409
} else {
set.status = 500
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update user',
}
}
}, UpdateUserModel)
// Delete user
.delete('/:id', async ({ params, set }) => {
try {
const { id } = params
const deletedUser = await deleteUser(id)
if (!deletedUser) {
set.status = 404
return {
success: false,
error: 'User not found',
}
}
return {
success: true,
data: deletedUser,
}
} catch (error) {
set.status = 500
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete user',
}
}
}, DeleteUserModel)
// Update user password
.patch('/:id/password', async ({ params, body, set }) => {
try {
const { id } = params
const updatedUser = await updateUserPassword(id, body.password)
if (!updatedUser) {
set.status = 404
return {
success: false,
error: 'User not found',
}
}
return {
success: true,
data: updatedUser,
message: 'Password updated successfully',
}
} catch (error) {
set.status = 500
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update password',
}
}
}, UpdatePasswordModel)
+15
View File
@@ -0,0 +1,15 @@
export interface robot{
description: string
time: Date,
id: string | number,
type: string,
action: 'robot',
state:'thinking'|'generate'|'complete'
}
export interface user{
description: string,
time: Date,
id: number | string,
action:'user'
}
+2 -1
View File
@@ -1,4 +1,5 @@
export * from './model'
export * from './schedule'
export * from './platform'
export * from './mcp'
export * from './mcp'
export * from './chatInfo'
+18
View File
@@ -28,3 +28,21 @@ export type MCPConnection =
| StdioMCPConnection
| HTTPMCPConnection
| SSEMCPConnection
export interface MCPListItem{
id: string;
type: string;
name: string;
config: {
cwd: string;
env: Record<string, string>;
args: string[];
type: string;
command: string;
};
active: boolean;
user: string;
createdAt: string;
updatedAt: string;
}
+15
View File
@@ -63,3 +63,18 @@ export interface ChatModel extends BaseModel {
}
export type Model = EmbeddingModel | ChatModel
// 表格当中model的类型
export interface ModelTable {
apiKey: string,
baseUrl: string,
clientType: 'OpenAI' | 'Anthropic' | 'Google',
modelId: string,
name: string,
type: 'chat' | 'embedding',
id: string,
defaultChatModel: boolean,
defaultEmbeddingModel: boolean,
defaultSummaryModel: boolean
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "#/components",
"composables": "#/composables",
"utils": "#/lib/utils",
"ui": "#/components",
"lib": "#/lib"
},
"iconLibrary": "lucide"
}
+7 -2
View File
@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./style.css": "./src/style.css"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -19,6 +20,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -26,7 +29,9 @@
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue-sonner": "^2.0.9"
"vee-validate": "^4.15.1",
"vue-sonner": "^2.0.9",
"zod": "3.25.76"
},
"peerDependencies": {
"vue": "^3.5.26"
+43
View File
@@ -0,0 +1,43 @@
import fs from 'fs'
import path from 'path'
const rootDir = path.resolve(import.meta.dirname, '../src')
const readDir = path.resolve(rootDir, './components')
const outputDir = path.resolve(rootDir, './index.ts')
async function readDirName(){
const pathList:Awaited<string[]> = await new Promise((resolve, reject) => {
fs.readdir(readDir, (err, data) => {
if (err) {
reject(err)
}
resolve(data)
})
})
return pathList
}
async function writeExportFile(pathList: string[]) {
const pathListStr = pathList.map(fileName => {
return `export * from './components/${fileName}/index'`
})
await new Promise((resolve, reject) => {
fs.writeFile(outputDir, pathListStr.join('\r\n'), (err) => {
if (err) {
reject(err)
}
resolve(undefined)
})
})
}
async function generate() {
try {
const list = await readDirName()
writeExportFile(list)
} catch(error) {
console.error(error)
}
}
generate()
+9 -9
View File
@@ -1,18 +1,18 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from '#/lib/utils'
import { badgeVariants } from '.'
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="props.class"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { MoreHorizontal } from "lucide-vue-next"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from '#/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { ChevronRight } from "lucide-vue-next"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from "./Breadcrumb.vue"
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue"
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue"
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue"
export { default as BreadcrumbList } from "./BreadcrumbList.vue"
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue"
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="slotProps"
data-slot="collapsible"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from "reka-ui"
import { CollapsibleContent } from "reka-ui"
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from "reka-ui"
import { CollapsibleTrigger } from "reka-ui"
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>
@@ -0,0 +1,3 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { Slot } from "reka-ui"
import { useFormField } from "./useFormField"
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
import { useFormField } from "./useFormField"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { useId } from "reka-ui"
import { provide } from "vue"
import { cn } from '#/lib/utils'
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
</script>
<template>
<div
data-slot="form-item"
:class="cn('grid gap-2', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
import { Label } from '#/components/label'
import { useFormField } from "./useFormField"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const { error, formItemId } = useFormField()
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn(
'data-[error=true]:text-destructive',
props.class,
)"
:for="formItemId"
>
<slot />
</Label>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { ErrorMessage } from "vee-validate"
import { toValue } from "vue"
import { cn } from '#/lib/utils'
import { useFormField } from "./useFormField"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { name, formMessageId } = useFormField()
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
/>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as FormControl } from "./FormControl.vue"
export { default as FormDescription } from "./FormDescription.vue"
export { default as FormItem } from "./FormItem.vue"
export { default as FormLabel } from "./FormLabel.vue"
export { default as FormMessage } from "./FormMessage.vue"
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"
@@ -0,0 +1,4 @@
import type { InjectionKey } from "vue"
export const FORM_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<string>
@@ -0,0 +1,30 @@
import { FieldContextKey } from "vee-validate"
import { computed, inject } from "vue"
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
if (!fieldContext)
throw new Error("useFormField should be used within <FormField>")
const { name, errorMessage: error, meta } = fieldContext
const id = fieldItemContext
const fieldState = {
valid: computed(() => meta.valid),
isDirty: computed(() => meta.dirty),
isTouched: computed(() => meta.touched),
error,
}
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
+7 -9
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
@@ -16,9 +16,7 @@ const delegatedProps = reactiveOmit(props, 'class')
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none text-gray-900 dark:text-gray-100',
'group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
+1 -1
View File
@@ -1 +1 @@
export { default as Label } from './Label.vue'
export { default as Label } from "./Label.vue"
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { AcceptableValue } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit, useVModel } from "@vueuse/core"
import { ChevronDownIcon } from "lucide-vue-next"
import { cn } from '#/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{ modelValue?: AcceptableValue | AcceptableValue[], class?: HTMLAttributes["class"] }>()
const emit = defineEmits<{
"update:modelValue": AcceptableValue
}>()
const modelValue = useVModel(props, "modelValue", emit, {
passive: true,
defaultValue: "",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<div
class="group/native-select relative w-fit has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
v-bind="{ ...$attrs, ...delegatedProps }"
v-model="modelValue"
data-slot="native-select"
:class="cn(
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
<slot />
</select>
<ChevronDownIcon
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
</template>
@@ -0,0 +1,15 @@
<!-- @fallthroughAttributes true -->
<!-- @strictTemplates true -->
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<optgroup data-slot="native-select-optgroup" :class="cn('bg-popover text-popover-foreground', props.class)">
<slot />
</optgroup>
</template>
@@ -0,0 +1,15 @@
<!-- @fallthroughAttributes true -->
<!-- @strictTemplates true -->
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<option data-slot="native-select-option" :class="cn('bg-popover text-popover-foreground', props.class)">
<slot />
</option>
</template>
@@ -0,0 +1,3 @@
export { default as NativeSelect } from "./NativeSelect.vue"
export { default as NativeSelectOptGroup } from "./NativeSelectOptGroup.vue"
export { default as NativeSelectOption } from "./NativeSelectOption.vue"
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationRootEmits, PaginationRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PaginationListProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationList } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<PaginationListProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { MoreHorizontal } from "lucide-vue-next"
import { PaginationEllipsis } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationFirstProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '#/components/button'
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeftIcon } from "lucide-vue-next"
import { PaginationFirst, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
import { buttonVariants } from '#/components/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationListItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '#/components/button'
import { reactiveOmit } from "@vueuse/core"
import { PaginationListItem } from "reka-ui"
import { cn } from '#/lib/utils'
import { buttonVariants } from '#/components/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
isActive?: boolean
}>(), {
size: "icon",
})
const delegatedProps = reactiveOmit(props, "class", "size", "isActive")
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationLastProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '#/components/button'
import { reactiveOmit } from "@vueuse/core"
import { ChevronRightIcon } from "lucide-vue-next"
import { PaginationLast, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
import { buttonVariants } from '#/components/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '#/components/button'
import { reactiveOmit } from "@vueuse/core"
import { ChevronRightIcon } from "lucide-vue-next"
import { PaginationNext, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
import { buttonVariants } from '#/components/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '#/components/button'
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeftIcon } from "lucide-vue-next"
import { PaginationPrev, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
import { buttonVariants } from '#/components/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>
@@ -0,0 +1,8 @@
export { default as Pagination } from "./Pagination.vue"
export { default as PaginationContent } from "./PaginationContent.vue"
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
export { default as PaginationFirst } from "./PaginationFirst.vue"
export { default as PaginationItem } from "./PaginationItem.vue"
export { default as PaginationLast } from "./PaginationLast.vue"
export { default as PaginationNext } from "./PaginationNext.vue"
export { default as PaginationPrevious } from "./PaginationPrevious.vue"
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaViewport,
} from "reka-ui"
import { cn } from '#/lib/utils'
import ScrollBar from "./ScrollBar.vue"
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ScrollAreaRoot
data-slot="scroll-area"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<ScrollAreaViewport
data-slot="scroll-area-viewport"
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui"
import { cn } from '#/lib/utils'
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
orientation: "vertical",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
v-bind="delegatedProps"
:class="
cn('flex touch-none p-px transition-colors select-none',
orientation === 'vertical'
&& 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal'
&& 'h-2.5 flex-col border-t border-t-transparent',
props.class)"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaScrollbar>
</template>
@@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from '#/lib/utils'
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from '#/lib/utils'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>
@@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"
+10
View File
@@ -0,0 +1,10 @@
import type { Updater } from "@tanstack/vue-table"
import type { Ref } from "vue"
import { isFunction } from "@tanstack/vue-table"
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { TagsInputRootEmits, TagsInputRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TagsInputRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<TagsInputRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<TagsInputRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TagsInputRoot
v-slot="slotProps" v-bind="forwarded" :class="cn(
'flex flex-wrap gap-2 items-center rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class)"
>
<slot v-bind="slotProps" />
</TagsInputRoot>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { TagsInputInputProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TagsInputInput, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<TagsInputInputProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputInput v-bind="forwardedProps" :class="cn('text-sm min-h-5 focus:outline-none flex-1 bg-transparent px-1', props.class)" />
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { TagsInputItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TagsInputItem, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<TagsInputItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItem v-bind="forwardedProps" :class="cn('flex h-5 items-center rounded-md bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background', props.class)">
<slot />
</TagsInputItem>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { TagsInputItemDeleteProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import { TagsInputItemDelete, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<TagsInputItemDeleteProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemDelete v-bind="forwardedProps" :class="cn('flex rounded bg-transparent mr-1', props.class)">
<slot>
<X class="w-4 h-4" />
</slot>
</TagsInputItemDelete>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { TagsInputItemTextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TagsInputItemText, useForwardProps } from "reka-ui"
import { cn } from '#/lib/utils'
const props = defineProps<TagsInputItemTextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TagsInputItemText v-bind="forwardedProps" :class="cn('py-0.5 px-2 text-sm rounded bg-transparent', props.class)" />
</template>
@@ -0,0 +1,5 @@
export { default as TagsInput } from "./TagsInput.vue"
export { default as TagsInputInput } from "./TagsInputInput.vue"
export { default as TagsInputItem } from "./TagsInputItem.vue"
export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue"
export { default as TagsInputItemText } from "./TagsInputItemText.vue"
+36 -30
View File
@@ -1,30 +1,36 @@
// Generated by scripts/gen-entry.ts. Do not edit manually.
export * from './components/alert/index'
export * from './components/avatar/index'
export * from './components/badge/index'
export * from './components/button/index'
export * from './components/button-group/index'
export * from './components/card/index'
export * from './components/checkbox/index'
export * from './components/combobox/index'
export * from './components/context-menu/index'
export * from './components/dialog/index'
export * from './components/dropdown-menu/index'
export * from './components/input/index'
export * from './components/input-group/index'
export * from './components/kbd/index'
export * from './components/label/index'
export * from './components/radio-group/index'
export * from './components/select/index'
export * from './components/separator/index'
export * from './components/sheet/index'
export * from './components/sidebar/index'
export * from './components/skeleton/index'
export * from './components/slider/index'
export * from './components/sonner/index'
export * from './components/spinner/index'
export * from './components/switch/index'
export * from './components/tabs/index'
export * from './components/textarea/index'
export * from './components/tooltip/index'
export * from './components/alert/index'
export * from './components/avatar/index'
export * from './components/badge/index'
export * from './components/breadcrumb/index'
export * from './components/button/index'
export * from './components/button-group/index'
export * from './components/card/index'
export * from './components/checkbox/index'
export * from './components/collapsible/index'
export * from './components/combobox/index'
export * from './components/context-menu/index'
export * from './components/dialog/index'
export * from './components/dropdown-menu/index'
export * from './components/form/index'
export * from './components/input/index'
export * from './components/input-group/index'
export * from './components/kbd/index'
export * from './components/label/index'
export * from './components/native-select/index'
export * from './components/pagination/index'
export * from './components/radio-group/index'
export * from './components/scroll-area/index'
export * from './components/select/index'
export * from './components/separator/index'
export * from './components/sheet/index'
export * from './components/sidebar/index'
export * from './components/skeleton/index'
export * from './components/slider/index'
export * from './components/sonner/index'
export * from './components/spinner/index'
export * from './components/switch/index'
export * from './components/table/index'
export * from './components/tabs/index'
export * from './components/tags-input/index'
export * from './components/textarea/index'
export * from './components/tooltip/index'
-142
View File
@@ -1,144 +1,2 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 1rem;
/* Base colors - gray-50 / gray-900 */
--background: oklch(0.985 0 0); /* gray-50 */
--foreground: oklch(0.224 0 0); /* gray-900 */
/* Card - white with gray border */
--card: oklch(1 0 0); /* white */
--card-foreground: oklch(0.224 0 0); /* gray-900 */
/* Popover - white */
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.224 0 0);
/* Primary - blue-500 */
--primary: oklch(0.572 0.188 255.29); /* blue-500 */
--primary-foreground: oklch(1 0 0); /* white */
/* Secondary - gray-100 */
--secondary: oklch(0.965 0 0); /* gray-100 */
--secondary-foreground: oklch(0.224 0 0); /* gray-900 */
/* Muted - gray-100 / gray-500 */
--muted: oklch(0.965 0 0); /* gray-100 */
--muted-foreground: oklch(0.539 0 0); /* gray-500 */
/* Accent - gray-100 */
--accent: oklch(0.965 0 0); /* gray-100 */
--accent-foreground: oklch(0.224 0 0); /* gray-900 */
/* Destructive - red-500 */
--destructive: oklch(0.577 0.245 27.325);
/* Border - gray-200 */
--border: oklch(0.922 0 0); /* gray-200 */
--input: oklch(0.922 0 0); /* gray-200 */
/* Ring - blue-500 */
--ring: oklch(0.572 0.188 255.29); /* blue-500 */
/* Charts */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
/* Sidebar - white */
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.224 0 0);
--sidebar-primary: oklch(0.572 0.188 255.29); /* blue-500 */
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.965 0 0); /* gray-100 */
--sidebar-accent-foreground: oklch(0.224 0 0);
--sidebar-border: oklch(0.922 0 0); /* gray-200 */
--sidebar-ring: oklch(0.572 0.188 255.29); /* blue-500 */
}
.dark {
/* Base colors - gray-800 / gray-100 */
--background: oklch(0.298 0 0); /* gray-800 */
--foreground: oklch(0.965 0 0); /* gray-100 */
/* Card - gray-900 with gray-700 border */
--card: oklch(0.224 0 0); /* gray-900 */
--card-foreground: oklch(0.965 0 0); /* gray-100 */
/* Popover - gray-900 */
--popover: oklch(0.224 0 0);
--popover-foreground: oklch(0.965 0 0);
/* Primary - blue-500 */
--primary: oklch(0.572 0.188 255.29); /* blue-500 */
--primary-foreground: oklch(1 0 0); /* white */
/* Secondary - gray-700 */
--secondary: oklch(0.427 0 0); /* gray-700 */
--secondary-foreground: oklch(0.965 0 0); /* gray-100 */
/* Muted - gray-700 / gray-400 */
--muted: oklch(0.427 0 0); /* gray-700 */
--muted-foreground: oklch(0.642 0 0); /* gray-400 */
/* Accent - gray-700 */
--accent: oklch(0.427 0 0); /* gray-700 */
--accent-foreground: oklch(0.965 0 0); /* gray-100 */
/* Destructive - red-500 */
--destructive: oklch(0.704 0.191 22.216);
/* Border - gray-700 */
--border: oklch(0.427 0 0); /* gray-700 */
--input: oklch(0.427 0 0); /* gray-700 */
/* Ring - blue-500 */
--ring: oklch(0.572 0.188 255.29); /* blue-500 */
/* Charts */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
/* Sidebar - gray-900 */
--sidebar: oklch(0.224 0 0);
--sidebar-foreground: oklch(0.965 0 0);
--sidebar-primary: oklch(0.572 0.188 255.29); /* blue-500 */
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.427 0 0); /* gray-700 */
--sidebar-accent-foreground: oklch(0.965 0 0);
--sidebar-border: oklch(0.427 0 0); /* gray-700 */
--sidebar-ring: oklch(0.572 0.188 255.29); /* blue-500 */
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
-1
View File
@@ -4,7 +4,6 @@
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"#/*": ["./src/*"]
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
export default defineConfig({
plugins: [
vue(),
tailwindcss()
],
resolve: {
alias: {
'#': path.resolve(__dirname, './src')
}
}
})
+44
View File
@@ -0,0 +1,44 @@
# Markdown 语法测试文档
## 1. 标题层级
### 一级标题
#### 二级标题
##### 三级标题
###### 六级标题
## 2. 文本格式
**粗体文本**
*斜体文本*
~~删除线文本~~
`代码片段`
> 引用文本
> 可以多行
## 3. 列表
### 无序列表
- 项目一
- 项目二
- 子项目
- 项目三
### 有序列表
1. 第一步
2. 第二步
3. 第三步
## 4. 链接与图片
[百度](https://www.baidu.com)
![Markdown Logo](https://markdown-here.com/img/icon256.png)
## 5. 表格
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25 | 开发 |
| 李四 | 30 | 测试 |
## 6. 代码块
```python
def hello_world():
print("Hello, World!")
+18 -1
View File
@@ -9,16 +9,33 @@
"start": "vite preview"
},
"dependencies": {
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
"@memoh/shared": "workspace:*",
"@memoh/ui": "workspace:*",
"@pinia/colada": "^0.21.1",
"@tailwindcss/vite": "^4.1.18",
"@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",
"mermaid": "^11.12.2",
"modern-css-reset": "^1.4.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"shiki": "^3.21.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.18",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"vee-validate": "^4.15.1",
"vue": "^3.5.24",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/node": "^24.10.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+53 -4
View File
@@ -1,10 +1,59 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Alert, Button } from '@memoh/ui'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@memoh/ui'
import SvgIcon from '@jamescoyle/vue-icon'
import { mdiTranslate, mdiBrightness6 } from '@mdi/js'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const modeToggleMap:Record<'dark'|'light','dark'|'light'> = {
dark: 'light',
light:'dark'
}
console.log(mode.value)
const toggleMode = () => {
if (mode.value !== 'auto') {
mode.value = modeToggleMap[mode.value]
}
}
</script>
<template>
<RouterView />
<Alert>Hello</Alert>
<Button>Click me</Button>
<section>
<div
class="fixed top-0 flex right-8 z-9999 [&:is(:has([data-state=open]))_.translate-icon]:opacity-100 align h-16 items-center"
>
<DropdownMenu>
<DropdownMenuTrigger class="ml-auto mr-4 cursor-pointer">
<svg-icon
type="mdi"
:path="mdiTranslate"
class="translate-icon opacity-30 hover:opacity-100"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="$i18n.locale= 'zh'">
中文
</DropdownMenuItem>
<DropdownMenuItem @click="$i18n.locale = 'en'">
English
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<svg-icon
type="mdi"
:path="mdiBrightness6"
class="translate-icon opacity-30 hover:opacity-100 cursor-pointer"
@click="toggleMode"
/>
</div>
<RouterView />
</section>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,188 @@
<template>
<section class="flex">
<Dialog v-model:open="open ">
<DialogTrigger as-child>
<Button
variant="default"
class="ml-auto my-4"
>
添加平台
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-106.25">
<form @submit="addPlatform">
<DialogHeader>
<DialogTitle>添加平台</DialogTitle>
<DialogDescription class="mb-4">
为模型添加使用平台
</DialogDescription>
</DialogHeader>
<div>
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<FormLabel class="mb-2">
Name
</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入Name"
v-bind="componentField"
autocomplete="name"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config"
>
<FormItem>
<FormLabel class="mb-2">
Config
</FormLabel>
<FormControl>
<TagsInput
:add-on-blur="true"
:model-value="configList"
:convert-value="tagStr => {
if (/^\w+\:\w+$/.test(tagStr)) {
return tagStr
}
return ''
}"
@update:model-value="(env) => {
configList = env.filter(Boolean) as string[]
const curConfig: Record<string,string> = {}
configList.forEach(envItem => {
const [key, value] = envItem.split(`:`);
if (key && value) {
curConfig[key] = value
}
})
componentField['onUpdate:modelValue']?.(curConfig)
}"
>
<TagsInputItem
v-for="(value, index) in configList"
:key="index"
:value="value as string"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
placeholder="请输入Env"
class="w-full py-1"
/>
</TagsInput>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="active"
>
<FormItem>
<FormLabel class="mb-2">
是否立即使用
</FormLabel>
<FormControl>
<Switch
id="airplane-mode"
:model-value="componentField.modelValue"
@update:model-value="componentField['onUpdate:modelValue']"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit">
添加MCP
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</template>
<script setup lang="ts">
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
FormField,
FormControl,
FormItem,
FormLabel,
FormMessage,
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
Switch,
} from '@memoh/ui'
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'
const configList=ref<string[]>([])
const validataSchema = toTypedSchema(z.object({
name: z.string().min(1),
config: z.looseObject({}),
active:z.coerce.boolean()
}))
const form = useForm({
validationSchema:validataSchema
})
const {mutate:addFetchPlatform}=useMutation({
mutation: (data: Parameters<(Parameters<typeof form.handleSubmit>)[0]>[0]) => request({
url: '/platform/',
data,
method:'post'
})
})
const addPlatform = form.handleSubmit(async (value) => {
try {
await addFetchPlatform(value)
open.value=false
} catch {
return
}
})
const open=inject('open',ref<boolean>(false))
</script>
@@ -0,0 +1,91 @@
<template>
<div class="flex gap-4 items-start">
<div class=" p-2 rounded-full bg-[#F9F9F9] dark:bg-[#666] ">
<svg-icon
type="mdi"
:path="mdiRobotOutline"
/>
</div>
<section class="w-[90%]">
<sup class="font-semibold">
{{ robotSay.type }}
</sup>
<p class="leading-7 text-muted-foreground break-all">
<template v-if="robotSay.state==='thinking'">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle
cx="4"
cy="12"
r="3"
fill="currentColor"
>
<animate
id="SVG7x14Dcom"
fill="freeze"
attributeName="opacity"
begin="0;SVGqSjG0dUp.end-0.25s"
dur="0.75s"
values="1;0.2"
/>
</circle>
<circle
cx="12"
cy="12"
r="3"
fill="currentColor"
opacity="0.4"
>
<animate
fill="freeze"
attributeName="opacity"
begin="SVG7x14Dcom.begin+0.15s"
dur="0.75s"
values="1;0.2"
/>
</circle>
<circle
cx="20"
cy="12"
r="3"
fill="currentColor"
opacity="0.3"
>
<animate
id="SVGqSjG0dUp"
fill="freeze"
attributeName="opacity"
begin="SVG7x14Dcom.begin+0.3s"
dur="0.75s"
values="1;0.2"
/>
</circle>
</svg>
</template>
<template v-else>
<MarkdownRender
:content="robotSay.description"
custom-id="chat-answer"
/>
</template>
</p>
</section>
</div>
</template>
<script setup lang="ts">
import SvgIcon from '@jamescoyle/vue-icon'
import { mdiRobotOutline } from '@mdi/js'
import type { robot } from '@memoh/shared'
import MarkdownRender,{enableKatex,enableMermaid} from 'markstream-vue'
enableKatex()
enableMermaid()
const {robotSay}=defineProps<{
robotSay: robot
}>()
</script>
@@ -0,0 +1,17 @@
<template>
<div class="flex">
<p
class="leading-7 not-first:mt-6 max-w-[90%] ml-auto text-muted-foreground bg-[#F9F9F9] p-4 rounded-xl rounded-tr-none break-all dark:bg-[#1C1917]
"
>
{{ userSay.description }}
</p>
</div>
</template>
<script setup lang="ts">
import type { user } from '@memoh/shared'
const { userSay } = defineProps<{
userSay: user
}>()
</script>
@@ -0,0 +1,111 @@
<template>
<div
ref="displayContainer"
class="flex flex-col gap-4"
>
<template
v-for="chatItem in chatList"
:key="chatItem.id"
>
<UserChat
v-if="chatItem.action === 'user'"
:user-say="chatItem"
/>
<RobotChat
v-if="chatItem.action === 'robot'"
:robot-say="chatItem"
/>
</template>
</div>
</template>
<script setup lang="ts">
import UserChat from './UserChat/index.vue'
import RobotChat from './RobotChat/index.vue'
import { inject, nextTick, ref, watch } from 'vue'
import { useElementBounding } from '@vueuse/core'
import { useChatList } from '@/store/ChatList'
import { onBeforeRouteLeave } from 'vue-router'
import { storeToRefs } from 'pinia'
// 模拟一下数据
const {chatList,add} = useChatList()
const { loading}=storeToRefs(useChatList())
const chatSay = inject('chatSay', ref(''))
// 模拟一下对话
watch(chatSay, () => {
if (chatSay.value) {
add({
description: chatSay.value,
time: new Date(),
action: 'user',
id: 1
})
add({
description: '',
time: new Date(),
action: 'robot',
id: 2,
type: 'Openai Gpt5',
state:'thinking'
})
chatSay.value=''
}
}, {
immediate: true
})
const displayContainer = ref()
const { height,top } = useElementBounding(displayContainer)
let prevScroll = 0, curScroll = 0, autoScroll = true,cacheScroll=0
watch(top, () => {
const container = displayContainer.value?.parentElement?.parentElement
if (height.value === 0) {
autoScroll = false
prevScroll = curScroll=0
}
if ((container?.scrollHeight - container.clientHeight - container.scrollTop) < 1) {
autoScroll = true
prevScroll=curScroll=container.scrollTop
}
})
watch(height, (newVal,oldVal) => {
const container = displayContainer.value?.parentElement?.parentElement
if (container) {
curScroll = container.scrollTop
if (curScroll < prevScroll) {
autoScroll = false
}
prevScroll = curScroll
}
if (oldVal === 0 && newVal > container.clientHeight) {
nextTick(() => {
container.scrollTo({
top: cacheScroll,
})
})
return
}
if (!(container && (container?.scrollHeight - container.clientHeight - container.scrollTop) < 1) && autoScroll&&loading.value) {
container.scrollTo({
top: container?.scrollHeight - container.clientHeight,
behavior: 'smooth',
})
}
})
onBeforeRouteLeave(() => {
const container = displayContainer.value?.parentElement?.parentElement
if (container) {
cacheScroll = container.scrollTop
}
})
</script>
@@ -0,0 +1,327 @@
<template>
<section class="flex">
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button
variant="default"
class="ml-auto my-4"
>
{{ $t("button.add",{msg:"MCP"}) }}
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-106.25">
<form @submit="createMCP">
<DialogHeader>
<DialogTitle> {{ $t("button.add", { msg: "MCP" }) }}</DialogTitle>
<DialogDescription class="mb-4">
添加MCP完成操作
</DialogDescription>
</DialogHeader>
<div>
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<FormLabel class="mb-2">
Name
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('prompt.enter', { msg: 'Name' })"
v-bind="componentField"
autocomplete="name"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.type"
>
<FormItem>
<FormLabel class="mb-2">
Type
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="$t('prompt.select', { msg: 'Type' })"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="stdio">
Stdio
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.cwd"
>
<FormItem>
<FormLabel class="mb-2">
Cwd
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('prompt.enter', { msg: 'cwd' })"
v-bind="componentField"
autocomplete="cwd"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.command"
>
<FormItem>
<FormLabel class="mb-2">
Command
</FormLabel>
<FormControl>
<Input
:placeholder="$t('prompt.enter', { msg: 'Command' })"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.args"
>
<FormItem>
<FormLabel class="mb-2">
Arguments
</FormLabel>
<FormControl>
<TagsInput
v-model="componentField.modelValue"
:add-on-blur="true"
:duplicate="true"
@update:model-value="componentField['onUpdate:modelValue']"
>
<TagsInputItem
v-for="item in componentField.modelValue"
:key="item"
:value="item"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('prompt.enter', { msg: 'Arguments' })"
class="w-full py-1"
/>
</TagsInput>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.env"
>
<FormItem>
<FormLabel class="mb-2">
Env
</FormLabel>
<FormControl>
<TagsInput
:add-on-blur="true"
:model-value="envList"
:convert-value="tagStr => {
if (/^\w+\:\w+$/.test(tagStr)) {
return tagStr
}
return ''
}"
@update:model-value="(env) => {
envList = env.filter(Boolean) as string[]
const curEnvObject: { [key in string]: string } = {}
envList.forEach(envItem => {
const [key, value] = envItem.split(`:`);
if (key && value) {
curEnvObject[key] = value
}
})
componentField['onUpdate:modelValue']?.(curEnvObject)
}"
>
<TagsInputItem
v-for="(value, index) in envList"
:key="index"
:value="value as string"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:placeholder="$t('prompt.enter', { msg: 'Env' })"
class="w-full py-1"
/>
</TagsInput>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="active"
>
<FormItem>
<FormControl>
<section class="flex gap-4">
<Label for="airplane-mode">{{ $t('state.open') }}</Label>
<Switch
id="airplane-mode"
:model-value="componentField.modelValue"
@update:model-value="componentField['onUpdate:modelValue']"
/>
</section>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit">
{{ $t("button.add", { msg: "MCP" }) }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</template>
<script setup lang="ts">
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
FormField,
FormControl,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
FormItem,
FormLabel,
FormMessage,
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
Switch,
Label
} from '@memoh/ui'
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'
const validateSchema = toTypedSchema(z.object({
name: z.string().min(1),
config: z.object({
type: z.string().min(1),
command: z.string().min(1),
args: z.array(z.coerce.string().check(z.minLength(1))).min(1),
env: z.looseObject({}),
cwd: z.string().min(1)
}),
active: z.coerce.boolean()
}))
const envList = ref<string[]>([])
const form = useForm({
validationSchema: validateSchema
})
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 open = inject('open', ref(false))
const mcpEditData = inject('mcpEditData', ref<{
name: string,
config: MCPType['config'],
active: boolean
id: string
} | null>(null))
watch(open, () => {
if (open.value && mcpEditData.value) {
form.setValues(mcpEditData.value)
}
if (!open.value) {
mcpEditData.value = null
}
}, {
immediate: true
})
const createMCP = form.handleSubmit(async (value) => {
try {
console.log(mcpEditData.value)
fetchMCP(value)
open.value = false
} catch {
return
}
})
</script>
@@ -0,0 +1,266 @@
<template>
<section class="ml-auto">
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button variant="default">
{{ $t("button.add",{msg:"Model"}) }}
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-106.25">
<form @submit="addModel">
<DialogHeader>
<DialogTitle> {{ $t("button.add", { msg: "Model" }) }}</DialogTitle>
<DialogDescription class="mb-4">
使用不用厂商的大模型
</DialogDescription>
</DialogHeader>
<div>
<FormField
v-slot="{ componentField }"
name="modelId"
>
<FormItem>
<FormLabel class="mb-2">
Model Name
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('prompt.enter',{msg:'Model Name'})"
v-bind="componentField"
autocomplete="modelId"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="baseUrl"
>
<FormItem>
<FormLabel class="mb-2">
Base Url
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('prompt.enter', { msg: 'Base Url' })"
v-bind="componentField"
autocomplete="baseurl"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="apiKey"
>
<FormItem>
<FormLabel class="mb-2">
Api Key
</FormLabel>
<FormControl>
<Input
:placeholder="$t('prompt.enter', { msg: 'Api Key' })"
autocomplete="apiKey"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="clientType"
>
<FormItem>
<FormLabel class="mb-2">
Client Type
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('prompt.select',{msg:'Client Type'})" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="OpenAI">
OpenAI
</SelectItem>
<SelectItem value="Anthropic">
Anthropic
</SelectItem>
<SelectItem value="Google">
Google
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<FormLabel class="mb-2">
Display Name
</FormLabel>
<FormControl>
<Input
:placeholder="$t('prompt.enter', { msg: 'Display Name' })"
autocomplete="name"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="type"
>
<FormItem>
<FormLabel class="mb-2">
Role
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('prompt.select', { msg: 'Role' })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="chat">
Chat
</SelectItem>
<SelectItem value="embedding">
embedding
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit">
{{ $t("button.add", { msg: "Model" }) }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
</template>
<script setup lang="ts">
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Button,
FormField,
FormControl,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
FormItem,
FormLabel,
FormMessage
} from '@memoh/ui'
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'
const formSchema = toTypedSchema(z.object({
modelId:z.string().min(1),
baseUrl: z.string().min(1),
apiKey: z.string().min(1),
clientType: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
}))
const form = useForm({
validationSchema: formSchema
})
const queryCache = useQueryCache()
type ModelInfoType= Parameters<(Parameters<typeof form.handleSubmit>)[0]>[0]
const { mutate: createModel } = useMutation({
mutation: (modelInfo:ModelInfoType ) => request({
url: '/model',
data: {
...modelInfo,
},
method: 'post'
}),
onSettled: () => { open.value = false; queryCache.invalidateQueries({ key: ['models'], exact: true })}
})
const { mutate: updateModel } = useMutation({
mutation: (modelInfo: ModelInfoType) => request({
url: `/model/${editInfo.value?.id}`,
data: {
...modelInfo,
},
method: 'PUT'
}),
onSettled: () => { open.value = false; queryCache.invalidateQueries({ key: ['models'], exact: true }) }
})
const addModel = form.handleSubmit(async (modelInfo) => {
if (editInfo.value?.id) {
updateModel(modelInfo)
} else {
createModel(modelInfo)
}
})
const open = inject<Ref<boolean>>('open',ref(false))
const editInfo = inject('editModelInfo',ref<null|(ModelInfoType&{id:string})>(null))
watch(open, () => {
if (open.value && editInfo?.value) {
form.setValues(editInfo.value)
}
}, {
immediate:true
})
</script>
@@ -0,0 +1,81 @@
<script setup lang="ts" generic="TData, TValue">
import type { ColumnDef } from '@tanstack/vue-table'
import {
FlexRender,
getCoreRowModel,
useVueTable,
} from '@tanstack/vue-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@memoh/ui'
const props = defineProps<{
columns: ColumnDef<TData, TValue>[]
data: TData[]
}>()
const table = useVueTable({
get data() { return props.data },
get columns() { return props.columns },
getCoreRowModel: getCoreRowModel(),
})
</script>
<template>
<div class="border rounded-md">
<Table>
<TableHeader>
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody class="[&_td]:py-4!">
<template v-if="table.getRowModel().rows?.length">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</TableCell>
</TableRow>
</template>
<template v-else>
<TableRow>
<TableCell
:colspan="columns.length"
class="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</template>
@@ -0,0 +1,75 @@
<template>
<SidebarInset>
<header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2 px-4">
<SidebarTrigger class="-ml-1" />
<Separator
orientation="vertical"
class="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<template
v-for="(breadcrumbItem, index) in curBreadcrumb"
:key="breadcrumbItem"
>
<template v-if="(index + 1) !== curBreadcrumb.length">
<BreadcrumbItem class="hidden md:block">
<BreadcrumbLink :href="breadcrumbItem.path">
{{ breadcrumbItem.breadcrumb }}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</template>
<BreadcrumbItem v-else>
<BreadcrumbPage>
{{ breadcrumbItem.breadcrumb }}
</BreadcrumbPage>
</BreadcrumbItem>
</template>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<main class="flex flex-1 flex-col gap-4 p-4 pt-0">
<router-view v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</router-view>
</main>
</SidebarInset>
</template>
<script setup lang="ts">
import {
SidebarTrigger, SidebarInset, Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Separator,
// DropdownMenu,
// DropdownMenuContent,
// DropdownMenuItem,
// DropdownMenuTrigger,
} from '@memoh/ui'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
// import SvgIcon from '@jamescoyle/vue-icon'
// import { mdiTranslate } from '@mdi/js'
const route = useRoute()
const curBreadcrumb = computed(() => {
return route.matched.map(routeItem => ({
path: routeItem.path,
breadcrumb: routeItem.meta['breadcrumb']
}))
})
</script>
@@ -0,0 +1,130 @@
<template>
<aside class="[&_[data-state=collapsed]_:is(.title-container,.exist-btn)]:hidden">
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<img
src="../../../public/logo.png"
width="80"
class="m-auto"
alt="logo.png"
>
<h4
class="scroll-m-20 text-xl font-semibold tracking-tight text-center text-muted-foreground title-container"
>
Memoh
</h4>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>
对话操作
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<Collapsible
v-for="sidebarItem in sidebarInfo"
:key="sidebarItem.title"
class="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton
:tooltip="sidebarItem.title"
@click="router.push({ name: sidebarItem.name })"
>
<svg-icon
type="mdi"
:path="sidebarItem.icon"
/>
<span>{{ sidebarItem.title }}</span>
</SidebarMenuButton>
</CollapsibleTrigger>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem class="flex justify-center exist-btn">
<Button
class="flex-[0.7] mb-10"
@click="exit"
>
{{ $t("login.exit") }}
</Button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarRail />
</Sidebar>
</aside>
</template>
<script setup lang="ts">
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
CollapsibleTrigger,
Collapsible,
Button
} from '@memoh/ui'
import { computed } from 'vue'
import SvgIcon from '@jamescoyle/vue-icon'
import { mdiRobot, mdiChatOutline, mdiCogBox, mdiListBox, mdiHome, mdiBookArrowDown } from '@mdi/js'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/User.ts'
import i18n from '@/i18n'
const router=useRouter()
const { t } = i18n.global
const sidebarInfo = computed(() => [
{
title: t('slidebar.chat'),
name: 'chat',
icon: mdiChatOutline
},
// {
// title: t('slidebar.home'),
// name: 'home',
// icon: mdiHome
// },
{
title: t('slidebar.model_setting'),
name: 'models',
icon: mdiRobot
}, {
title: t('slidebar.setting'),
name: 'settings',
icon: mdiCogBox
}, {
title: 'MCP',
name: 'mcp',
icon: mdiListBox
}, {
title: t('slidebar.platform'),
name: 'platform',
icon: mdiBookArrowDown
}
])
const { exitLogin } = useUserStore()
const exit = () => {
exitLogin()
router.replace({ name: 'Login' })
}
</script>
+25 -3
View File
@@ -1,7 +1,29 @@
import { createI18n } from 'vue-i18n'
import en from '@/i18n/locales/en.json'
import zh from '@/i18n/locales/zh.json'
import { computed } from 'vue'
const i18n = createI18n({
locale: 'en',
type enMessageSchema = typeof en
type zhMessageSchema = typeof zh;
const i18n = createI18n<[enMessageSchema, zhMessageSchema], 'en' | 'zh'>({
locale: 'zh',
legacy: false,
fallbackLocale: 'en',
messages: {
en,
zh
}
})
export default i18n
export default i18n
const t = i18n.global.t
export const i18nRef = (arg:string) => {
return computed(() => {
return t(arg)
})
}
+39
View File
@@ -0,0 +1,39 @@
{
"login": {
"username": "Username",
"password": "Password",
"login": "Login",
"register": "Register",
"forget": "Forgot your password?",
"exit": "Sign Out"
},
"prompt": {
"enter": "Please enter {msg}",
"select": "Please select {msg}"
},
"slidebar": {
"setting": "Settings",
"platform": "Platform",
"chat": "Create Chat",
"model_setting": "Model Settings",
"home": "Home"
},
"desc": {
"question": "your question"
},
"chat": {
"send": "Send",
"chat": "Chat"
},
"breadcrumb": {
"main": "Main"
},
"button": {
"edit": "Edit",
"delete": "Delete",
"add": "Add {msg}"
},
"state":{
"open":"Open"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"login": {
"username": "用户名",
"password": "密码",
"login": "登录",
"register": "注册",
"forget": "忘记密码?",
"exit": "退出登录"
},
"desc": {
"question": "您的问题"
},
"prompt": {
"enter": "请输入{msg}",
"select": "请选择{msg}"
},
"slidebar": {
"setting": "设置",
"platform": "平台",
"chat": "创建对话",
"model_setting": "模型配置",
"home": "主页"
},
"chat": {
"send": "发送",
"chat": "对话"
},
"breadcrumb": {
"main": "主菜单"
},
"button": {
"edit": "编辑",
"delete": "删除",
"add": "添加{msg}"
},
"state": {
"open": "Open"
}
}
@@ -0,0 +1,12 @@
<template>
<section class="flex">
<sidebar-provider>
<slot name="sidebar" />
<slot name="main" />
</sidebar-provider>
</section>
</template>
<script setup lang="ts">
import { SidebarProvider } from '@memoh/ui'
</script>
+10 -3
View File
@@ -1,13 +1,20 @@
import { createApp } from 'vue'
// @ts-ignore
import './style.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import i18n from './i18n'
import '@memoh/ui/style.css'
import { PiniaColada } from '@pinia/colada'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// @ts-ignore
import 'markstream-vue/index.css'
// @ts-ignore
import 'katex/dist/katex.min.css'
createApp(App)
.use(createPinia())
.use(createPinia().use(piniaPluginPersistedstate))
.use(PiniaColada)
.use(router)
.use(i18n)
.mount('#app')
.mount('#app')
+82
View File
@@ -0,0 +1,82 @@
<template>
<section class="h-[calc(100vh-calc(var(--spacing)*20))] max-w-187 gap-8 w-full *:w-full m-auto flex flex-col">
<!-- <ScrollArea class="flex-none w-full rounded-md border">
<div class="p-4">
<h4 class="mb-4 text-sm leading-none font-medium">
Tags
</h4>
<template
v-for="tag in 1000"
:key="tag"
>
<div class="text-sm">
{{ tag }}
</div>
</template>
</div>
</ScrollArea> -->
<section class="flex-1 h-0">
<ScrollArea
ref="chat-container"
class="max-h-full h-full w-full rounded-md border p-4 **:focus-visible:ring-0! "
>
<ChatList />
</ScrollArea>
</section>
<section class="flex-none relative">
<Textarea
v-model="curInputSay"
class="pb-16 pt-4"
:placeholder="$t('prompt.enter', { msg: $t('desc.question') })"
/>
<section class="absolute bottom-0 h-14 px-2 inset-x-0 flex items-center">
<Button
variant="default"
class="ml-auto"
@click="send"
>
<template v-if="!loading">
{{ $t('chat.send') }}
<svg-icon
type="mdi"
:path="mdiSendOutline"
/>
</template>
<img
v-else
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48Y2lyY2xlIGN4PSI0IiBjeT0iMTIiIHI9IjEuNSIgZmlsbD0iI2ZmZiI+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iciIgZHVyPSIwLjc1cyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMS41OzM7MS41Ii8+PC9jaXJjbGU+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iI2ZmZiI+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iciIgZHVyPSIwLjc1cyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMzsxLjU7MyIvPjwvY2lyY2xlPjxjaXJjbGUgY3g9IjIwIiBjeT0iMTIiIHI9IjEuNSIgZmlsbD0iI2ZmZiI+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iciIgZHVyPSIwLjc1cyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMS41OzM7MS41Ii8+PC9jaXJjbGU+PC9zdmc+"
alt="loading"
>
</Button>
</section>
</section>
</section>
</template>
<script setup lang="ts">
import {
ScrollArea,
Textarea,
Button
} from '@memoh/ui'
import SvgIcon from '@jamescoyle/vue-icon'
import { mdiSendOutline } from '@mdi/js'
import ChatList from '@/components/ChatList/index.vue'
import { provide, ref } from 'vue'
import { useChatList } from '@/store/ChatList'
import {storeToRefs} from 'pinia'
const chatSay = ref('')
const curInputSay = ref('')
const {loading}=storeToRefs(useChatList())
provide('chatSay', chatSay)
const send = () => {
if (loading.value === false) {
chatSay.value = curInputSay.value
curInputSay.value = ''
}
}
</script>
+5
View File
@@ -0,0 +1,5 @@
<template>
<section>
<h1>主页</h1>
</section>
</template>
View File
+159
View File
@@ -0,0 +1,159 @@
<template>
<section class="w-screen h-screen flex *:m-auto bg-linear-to-t from-[#BFA4A0] to-[#7784AC] ">
<section
v-if="!loading"
class="w-full max-w-sm flex flex-col gap-10 "
>
<section>
<img
src="../../../public/logo.png"
width="100"
alt="logo.png"
class="m-auto"
>
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight text-white text-center">
Memoh
</h3>
</section>
<form @submit="login">
<Card class="py-14">
<CardContent class="flex flex-col [&_input]:py-5">
<FormField
v-slot="{ componentField }"
name="username"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t("login.username") }}
</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="$t('prompt.enter', { msg: $t(`login.username`).toLocaleLowerCase() })"
v-bind="componentField"
autocomplete="username"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="password"
>
<FormItem>
<FormLabel class="mb-2">
{{ $t('login.password') }}
</FormLabel>
<FormControl>
<Input
type="password"
:placeholder="$t('prompt.enter',{msg:$t(`login.password`).toLocaleLowerCase()})"
autocomplete="password"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<div class="flex">
<a
href="#"
class="ml-auto inline-block text-sm underline mt-2"
>
{{ $t('login.forget') }}
</a>
</div>
</CardContent>
<CardFooter class="flex flex-col gap-4">
<Button
class="w-full"
type="submit"
@click="login"
>
{{ $t("login.login") }}
</Button>
<Button
variant="outline"
class="w-full"
>
{{ $t("login.register") }}
</Button>
</CardFooter>
</Card>
</form>
</section>
<section
v-else
class="fixed inset-0 flex"
>
<img
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJjdXJyZW50Q29sb3IiIGQ9Ik0xMiwxQTExLDExLDAsMSwwLDIzLDEyLDExLDExLDAsMCwwLDEyLDFabTAsMTlhOCw4LDAsMSwxLDgtOEE4LDgsMCwwLDEsMTIsMjBaIiBvcGFjaXR5PSIwLjI1Ii8+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMTAuMTQsMS4xNmExMSwxMSwwLDAsMC05LDguOTJBMS41OSwxLjU5LDAsMCwwLDIuNDYsMTIsMS41MiwxLjUyLDAsMCwwLDQuMTEsMTAuN2E4LDgsMCwwLDEsNi42Ni02LjYxQTEuNDIsMS40MiwwLDAsMCwxMiwyLjY5aDBBMS41NywxLjU3LDAsMCwwLDEwLjE0LDEuMTZaIj48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGR1cj0iMC43NXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB0eXBlPSJyb3RhdGUiIHZhbHVlcz0iMCAxMiAxMjszNjAgMTIgMTIiLz48L3BhdGg+PC9zdmc+"
alt=""
width="80"
class="m-auto"
>
</section>
</section>
</template>
<script setup lang="ts">
import {
Card,
CardContent,
CardFooter,
Input,
Button,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@memoh/ui'
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.ts'
import { ref } from 'vue'
const router = useRouter()
const formSchema = toTypedSchema(z.object({
username: z.string().min(1),
password: z.string().min(1),
}))
const form = useForm({
validationSchema: formSchema,
})
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?.data
if (data?.token && data?.user) {
LoginHandle(data.user, data.token)
}
router.replace({
name:'Main'
})
} catch (error) {
return error
} finally {
loading.value=false
}
})
</script>
@@ -0,0 +1,22 @@
<template>
<section>
<MainLayout>
<template #sidebar>
<SideBar />
</template>
<template #main>
<MainContainer />
</template>
</MainLayout>
</section>
</template>
<script setup lang="ts">
import MainLayout from '@/layout/mainLayout/index.vue'
import SideBar from '@/components/Sidebar/index.vue'
import MainContainer from '@/components/MainContainer/index.vue'
import { provide,ref } from 'vue'
provide('sideBarIsOpen',ref(true))
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<section class="[&_td:last-child]:w-40">
<CreateMCP />
<DataTable
:columns="columns"
:data="mcpFormatData"
/>
</section>
</template>
<script setup lang="ts">
import { useQuery,useMutation,useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import { watch, h, provide,ref, computed,reactive } from 'vue'
import DataTable from '@/components/DataTable/index.vue'
import CreateMCP from '@/components/CreateMCP/index.vue'
import { type ColumnDef } from '@tanstack/vue-table'
import {
Badge,
Button
} from '@memoh/ui'
import { type MCPListItem as MCPType } from '@memoh/shared'
import { i18nRef } from '@/i18n'
const open = ref(false)
const editMCPData = ref<{
name: string,
config: MCPType['config'],
active: boolean
id:string
}|null>(null)
provide('open', open)
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 columns:ColumnDef<MCPType>[] = [
{
accessorKey: 'name',
header: () => h('div', { class: 'text-left py-4' }, 'Name'),
},
{
accessorKey: 'type',
header: () => h('div', { class: 'text-left' }, 'Type'),
},
{
accessorKey: 'config.command',
header: () => h('div', { class: 'text-left' }, 'Command'),
},
{
accessorKey: 'config.cwd',
header: () => h('div', { class: 'text-left' }, 'Cwd'),
},
{
accessorKey: 'config.args',
header: () => h('div', { class: 'text-left' }, 'Arguments'),
cell: ({ row }) => h('div', {class:'flex gap-4'}, row.original.config.args.map((argTxt) => {
return h(Badge, {
variant:'default'
},()=>argTxt)
}))
},
{
accessorKey: 'config.env',
header: () => h('div', { class: 'text-left' }, 'Env'),
cell: ({ row }) => h('div', { class: 'flex gap-4' }, Object.entries(row.original.config.env).map(([key,value]) => {
return h(Badge, {
variant: 'outline'
}, ()=>`${key}:${value}`)
}))
},
{
accessorKey: 'control',
header: () => h('div', { class: 'text-center' }, '操作'),
cell: ({ row }) => h('div', {class:'flex gap-2'}, [
h(Button, {
onClick() {
editMCPData.value = {
name: row.original.name,
config: {...row.original.config},
active: row.original.active,
id:row.original.id
}
open.value=true
}
}, ()=>i18nRef('button.edit').value),
h(Button, {
variant: 'destructive',
async onClick() {
try {
await DeleteMCP(row.original.id)
} catch {
return
}
}
},()=>i18nRef('button.delete').value)
])
}
]
const { data: mcpData } = useQuery({
key: ['mcp'],
query: () => request({
url: '/mcp/'
})
})
const mcpFormatData = computed(() => {
return mcpData.value?.data?.items??[]
})
</script>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
// import type { Payment } from '@/components/columns'
import { h, computed, ref, provide, watch, type ComputedRef, reactive } from 'vue'
import CreateModel from '@/components/CreateModel/index.vue'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import {
Button,
// Pagination,
// PaginationContent,
// PaginationEllipsis,
// PaginationItem,
// PaginationNext,
// PaginationPrevious,
Checkbox
} from '@memoh/ui'
import DataTable from '@/components/DataTable/index.vue'
import request from '@/utils/request'
import { type ColumnDef } from '@tanstack/vue-table'
import {type ModelTable as ModelType} from '@memoh/shared'
import { i18nRef } from '@/i18n'
const openDialogModel = ref(false)
const editModelInfo = ref<ModelType & { id: string } | null>(null)
provide('open', openDialogModel)
provide('editModelInfo', editModelInfo)
watch(openDialogModel, () => {
if (!openDialogModel.value) {
editModelInfo.value = null
}
}, {
immediate: true
})
const cacheQuery = useQueryCache()
const {
mutate: deleteModel,
} = useMutation({
mutation: (id: string) =>
request({
url: `model/${id}`,
method: 'DELETE'
}),
onSettled: () => {
cacheQuery.invalidateQueries({
key: ['models']
})
}
})
const {
mutate: setDefaultModel,
} = useMutation({
mutation: (payload: { id: string, type: string }) =>
request({
url: `/model/${payload.type}/default?userId=${payload.id}`,
method: 'get'
}),
onSettled: () => {
cacheQuery.invalidateQueries({
key: ['models']
})
}
})
const renderCheckDefault = () => {
return [...[{ title: 'Chat', key: 'chat', type: 'defaultChatModel' },
{ title: 'Summary', key: 'summary', type: 'defaultSummaryModel' },
{ title: 'Embedding', key: 'embedding', type: 'defaultEmbeddingModel' }].map((modelSetting) => (
{
accessorKey: `${modelSetting.key}`,
header: () => h('div', { class: 'text-left' }, modelSetting.title),
cell({ row }) {
const type = modelSetting.type as 'defaultChatModel' | 'defaultSummaryModel' | 'defaultEmbeddingModel'
return row.original.type === modelSetting.key ? h(Checkbox, {
state: row.original[type],
disabled: row.original[type] ? true : false,
'onUpdate:modelValue'(val) {
row.original[type] = val as boolean
setDefaultModel({
id: row.original.id,
type: modelSetting.key
})
}
}) : h('div')
}
} as ColumnDef<ModelType>
))]
}
const checkDefaultModel = ref(renderCheckDefault())
const columns: ComputedRef<ColumnDef<ModelType>[]> = computed(() => [
{
accessorKey: 'modelId',
header: () => h('div', { class: 'text-left py-4' }, 'Name'),
cell({ row }) {
return h('div', { class: 'text-left' }, row.getValue('modelId'))
}
},
{
accessorKey: 'baseUrl',
header: () => h('div', { class: 'text-left' }, 'Base Url'),
},
{
accessorKey: 'apiKey',
header: () => h('div', { class: 'text-left' }, 'Api Key'),
},
{
accessorKey: 'clientType',
header: () => h('div', { class: 'text-left' }, 'Client Type'),
},
{
accessorKey: 'name',
header: () => h('div', { class: 'text-left' }, 'Name'),
},
{
accessorKey: 'type',
header: () => h('div', { class: 'text-left' }, 'Type'),
},
...checkDefaultModel.value
,
{
accessorKey: 'control',
header: () => h('div', { class: 'text-center' }, '操作'),
cell: ({ row }) => h('div', { class: ' w-full flex justify-center gap-4' }, [h(Button, {
'onClick': () => {
editModelInfo.value = row.original
openDialogModel.value = true
}
}, () => i18nRef('button.edit').value), h(Button, {
variant: 'destructive', onClick() {
deleteModel(row.original.id)
}
}, () => i18nRef('button.delete').value)])
}
])
const { data: modelData } = useQuery({
key: ['models'],
async query() {
const fetchModeData = await request({
url: '/model'
})
const defaultModel = await request({
url: '/settings'
})
const defaultModelValue = defaultModel?.data?.data
fetchModeData.data.items = fetchModeData.data.items.map((item: { model: ModelType, id: 'string' }) => ({
id: item.id,
model: {
...item.model,
defaultChatModel: defaultModelValue?.defaultChatModel === item.id ? true : false,
defaultEmbeddingModel: defaultModelValue?.defaultEmbeddingModel === item.id ? true : false,
defaultSummaryModel: defaultModelValue?.defaultSummaryModel === item.id ? true : false
}
}))
return fetchModeData
}
})
watch(modelData, () => {
checkDefaultModel.value = renderCheckDefault()
})
const displayFormat = computed(() => {
return modelData.value?.data?.items?.map((currentModel: { model: Omit<ModelType, 'id'>, id: 'string' }) => ({ id: currentModel.id, ...currentModel.model })) ?? []
})
const pagination = computed(() => {
return modelData.value?.data.pagination ?? {}
})
</script>
<template>
<div class="w-full py-10 mx-auto">
<div class="flex mb-4">
<CreateModel />
</div>
<div class="[&_td:last-child]:w-45">
<DataTable
:columns="columns"
:data="displayFormat"
/>
</div>
<!-- <div class="flex flex-col mt-4">
<Pagination
v-slot="{ page }"
:total="pagination.value?.total ?? 0"
:items-per-page="10"
show-edges
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template
v-for="(item, index) in items"
:key="index"
>
<PaginationItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
:is-active="item.value === page"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis
v-else
:key="item.type"
:index="index"
class="w-9 h-9 flex items-center justify-center"
>
&#8230;
</PaginationEllipsis>
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</div> -->
</div>
</template>
+231
View File
@@ -0,0 +1,231 @@
<template>
<section>
<AddPlatform />
<div>
<menu class="grid grid-cols-4 gap-4 [&_li>*]:h-full">
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground">
平台:Telegram
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2"
type="1"
>
<li>功能1</li>
<li>功能2</li>
<li>功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button
class="ml-auto"
@click="open = true"
>
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground">
平台:Telegram
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2"
type="1"
>
<li>功能1</li>
<li>功能2</li>
<li>功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button class="ml-auto">
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground">
平台:Telegram
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2"
type="1"
>
<li>功能1</li>
<li>功能2</li>
<li>功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button class="ml-auto">
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground">
平台:Telegram
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2"
type="1"
>
<li>功能1</li>
<li>功能2</li>
<li>功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button class="ml-auto">
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground flex justify-between">
<header>
平台:Telegram
</header>
<Badge variant="outline">
运行中...
</Badge>
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2"
type="1"
>
<li>功能1</li>
<li>功能2</li>
<li>功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button
class="ml-auto"
@click="open=true"
>
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground">
平台:Telegram
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class=" [&>li]:mt-2 "
type="1"
>
<li>1. 功能1</li>
<li>2. 功能2</li>
<li>3. 功能2</li>
</ol>
</CardContent>
</CardHeader>
<CardContent>
<section class="flex" />
</CardContent>
<CardFooter class="flex gap-4">
<Switch />
<Button class="ml-auto">
编辑
</Button>
<Button variant="destructive">
删除
</Button>
</CardFooter>
</Card>
</li>
</menu>
</div>
</section>
</template>
<script setup lang="ts">
import { useMutation, useQuery } from '@pinia/colada'
import request from '@/utils/request'
import { watch, h, provide, ref } from 'vue'
import AddPlatform from '@/components/AddPlatform/index.vue'
import {Card,CardHeader,CardFooter,CardContent,CardTitle,Switch,Button, Badge } from '@memoh/ui'
const open = ref(false)
provide('open', open)
const { data: platformData } = useQuery({
key: ['platform'],
query: () => request({
url: '/platform/'
})
})
watch(platformData, () => {
console.log(platformData.value?.data)
})
</script>
+295
View File
@@ -0,0 +1,295 @@
<template>
<section>
<section class="max-w-187 m-auto">
<Card>
<form @submit="changeSetting">
<CardHeader>
<CardTitle class="text-2xl font-semibold tracking-tight">
Settings
</CardTitle>
<CardDescription>
Model Settings
</CardDescription>
</CardHeader>
<CardContent class="mt-4">
<FormField
v-slot="{ componentField }"
name="defaultChatModel"
>
<FormItem>
<FormLabel class="mb-2">
Chat Model
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('prompt.select',{msg:'Client Type'})" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="(modelItem, index) in modelType.chat"
:key="modelItem.id"
:value="(modelItem.model.apiKey + index)"
>
{{ modelItem.model.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="defaultEmbeddingModel"
>
<FormItem>
<FormLabel class="mb-2">
Embedding Model
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('prompt.select',{msg:'Embedding Type'})" />
</SelectTrigger>
<SelectContent v-if="modelType.embedding.length > 0">
<SelectGroup>
<SelectItem
v-for="(modelItem, index) in modelType.embedding"
:key="modelItem.id"
:value="(modelItem.model.apiKey + index)"
>
{{ modelItem.model.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</formcontrol>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="defaultSummaryModel"
>
<FormItem>
<FormLabel class="mb-2">
<!-- defaultSummaryModel -->
Summary Model
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('prompt.select', { msg: 'Summary Type' })" />
</SelectTrigger>
<SelectContent v-if="modelType.embedding.length > 0">
<SelectGroup>
<SelectItem
v-for="(modelItem, index) in modelType.summary"
:key="modelItem.id"
:value="(modelItem.model.apiKey + index)"
>
{{ modelItem.model.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="language"
>
<FormItem>
<FormLabel class="mb-2">
Language
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="$t('prompt.select', { msg: 'Language' })"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="ch">
中文
</SelectItem>
<SelectItem value="en">
English
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="maxContextLoadTime"
>
<FormItem>
<FormLabel class="mb-2">
<!-- defaultSummaryModel -->
Timeout
</FormLabel>
<FormControl>
<Input
:placeholder="$t('prompt.enter',{msg:'Timeout'})"
v-bind="componentField"
/>
</FormControl>
<blockquote class="h-5">
<FormMessage />
</blockquote>
</FormItem>
</FormField>
</CardContent>
<CardFooter class="flex">
<Button
class="ml-auto"
type="submit"
:disabled="diabeld"
>
Change
</Button>
</CardFooter>
</form>
</Card>
</section>
</section>
</template>
<script setup lang="ts">
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import request from '@/utils/request'
import { watch, reactive, computed } from 'vue'
import { type ModelTable } from '@memoh/shared'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm, useFormValues } from 'vee-validate'
import {
Input,
Card,
CardDescription,
CardHeader,
CardTitle,
CardContent,
FormField,
FormItem,
FormLabel,
FormControl,
Button,
FormMessage,
Select,
SelectTrigger,
SelectContent,
SelectValue,
SelectGroup,
SelectItem,
CardFooter
} from '@memoh/ui'
type ModelList = {
id: ModelTable['id'],
model: Omit<ModelTable, 'id' | 'defaultChatModel' | 'defaultEmbeddingModel' | 'defaultSummaryModel'>
};
const modelType = reactive<{
chat: ModelList[],
embedding: ModelList[],
summary: ModelList[]
}>({
chat: [],
embedding: [],
summary: []
})
const { data: settingData } = useQuery({
key: ['Setting'],
query: async () => {
const modelData = await request({
url: '/model/',
method: 'get'
})
for (const modelItems of modelData.data.items) {
let type = modelItems.model.type as keyof typeof modelType
modelType[type].push(modelItems)
}
return await request({
url: '/settings/',
method: 'get'
})
}
})
const formSchema = toTypedSchema(z.object({
defaultChatModel: z.any(),
defaultEmbeddingModel: z.any(),
defaultSummaryModel: z.any(),
maxContextLoadTime: z.coerce.number().min(1500),
language: z.literal(['ch', 'en'])
}))
const form = useForm({
validationSchema: formSchema
})
const currentSetting = useFormValues()
const diabeld = computed(() => {
return Object.keys(currentSetting.value).every((property) => {
const curKey = currentSetting.value[property]
const cacheKey = settingData.value?.data?.data?.[property]
if (curKey === cacheKey || Number(curKey) === Number(cacheKey)) {
return true
}
})
})
watch(settingData, () => {
form.setValues({
...(settingData.value?.data.data ?? {})
})
}, {
immediate: true
})
const cacheQuery=useQueryCache()
const { mutate: fetchSetting } = useMutation({
mutation: (data:typeof currentSetting.value) => request({
url: '/settings/',
data
}),
onSettled: () => {
cacheQuery.invalidateQueries({
key:['Setting']
})
}
})
const changeSetting = form.handleSubmit(async (value) => {
try {
await fetchSetting(value)
} catch {
return
}
})
</script>

Some files were not shown because too many files have changed in this diff Show More