282 lines
7.8 KiB
Vue
282 lines
7.8 KiB
Vue
<!-- eslint-disable @typescript-eslint/no-explicit-any -->
|
|
<script lang="ts" setup>
|
|
import type { FormContext } from 'vee-validate'
|
|
import type { AnyZodObject } from 'zod'
|
|
import dayjs from 'dayjs'
|
|
import type { LLMConversation } from '.'
|
|
|
|
const props = defineProps<{
|
|
formSchema: AnyZodObject
|
|
form: FormContext<any>
|
|
formFieldConfig?: {
|
|
[key: string]: {
|
|
component?: string
|
|
props?: Record<string, any>
|
|
[key: string]: any
|
|
}
|
|
}
|
|
conversations?: LLMConversation[]
|
|
activeConversationId?: string | null
|
|
disableUserInput?: boolean
|
|
}>()
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const currentConversation = computed(() => {
|
|
const { conversations, activeConversationId } = props
|
|
if (conversations && activeConversationId) {
|
|
return conversations.find((m) => m.id === activeConversationId)
|
|
}
|
|
return null
|
|
})
|
|
|
|
const messages = computed(() => {
|
|
const { conversations, activeConversationId } = props
|
|
if (conversations && activeConversationId) {
|
|
const conversation = conversations.find(
|
|
(m) => m.id === activeConversationId,
|
|
)
|
|
return conversation ? conversation.messages : []
|
|
}
|
|
return []
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'submit', values: FormContext<any>['values']): void
|
|
(e: 'update:conversationId', conversationId: string | null): void
|
|
(e: 'deleteConversation', conversationId: string): void
|
|
}>()
|
|
|
|
const onDeleteConversation = (conversationId: string) => {
|
|
emit('deleteConversation', conversationId)
|
|
if (conversationId === props.activeConversationId) {
|
|
emit('update:conversationId', null)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full flex flex-col gap-4">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<Button
|
|
v-if="activeConversationId"
|
|
variant="link"
|
|
size="sm"
|
|
@click="$emit('update:conversationId', null)"
|
|
>
|
|
<Icon name="tabler:arrow-back" />
|
|
返回
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<!-- Histories -->
|
|
<Popover>
|
|
<PopoverTrigger>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
<Icon name="tabler:history" />
|
|
历史记录
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="end"
|
|
class="p-0"
|
|
>
|
|
<ScrollArea
|
|
v-if="conversations?.length"
|
|
class="flex flex-col gap-2 p-3 h-[320px]"
|
|
>
|
|
<div
|
|
v-for="(conversation, i) in props.conversations"
|
|
:key="i"
|
|
class="flex items-start gap-2 p-2 rounded-md cursor-pointer relative"
|
|
:class="`${conversation.id === activeConversationId ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`"
|
|
@click="$emit('update:conversationId', conversation.id)"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="absolute top-1.5 right-2"
|
|
@click.stop="onDeleteConversation(conversation.id)"
|
|
>
|
|
<Icon name="tabler:trash" />
|
|
</Button>
|
|
|
|
<Icon
|
|
name="tabler:history"
|
|
class="mt-0.5"
|
|
/>
|
|
<div class="flex-1 text-sm font-medium flex flex-col">
|
|
<span class="w-2/3 text-ellipsis line-clamp-1">
|
|
{{ conversation.title || '历史对话' }}
|
|
</span>
|
|
<span
|
|
class="text-xs"
|
|
:class="`${conversation.id === activeConversationId ? 'text-primary-foreground/60' : 'text-muted-foreground'}`"
|
|
>
|
|
{{
|
|
dayjs((conversation.created_at || 0) * 1000).format(
|
|
'YYYY-MM-DD HH:mm:ss',
|
|
)
|
|
}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p
|
|
class="text-xs text-muted-foreground/60 font-medium text-center pt-2"
|
|
>
|
|
到底了
|
|
</p>
|
|
</ScrollArea>
|
|
<div
|
|
v-else
|
|
class="flex flex-col items-center justify-center gap-2 h-[320px]"
|
|
>
|
|
<Icon
|
|
name="tabler:history"
|
|
class="text-3xl text-muted-foreground"
|
|
/>
|
|
<p class="text-sm text-muted-foreground">暂无历史记录</p>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
<hr />
|
|
|
|
<!-- 消息区域调整 -->
|
|
<div
|
|
v-if="activeConversationId"
|
|
class="flex flex-col flex-1 gap-4 max-h-[calc(100vh-280px)]"
|
|
>
|
|
<div
|
|
class="flex-1 flex flex-col gap-6 overflow-y-auto scroll-smooth px-2 pb-4"
|
|
>
|
|
<div
|
|
v-for="(message, i) in messages"
|
|
:key="i"
|
|
class="w-full flex"
|
|
:class="`${message.role == 'user' ? 'justify-end' : 'justify-start'}`"
|
|
>
|
|
<div
|
|
class="gradient-border"
|
|
:class="
|
|
message.role === 'assistant' && !currentConversation?.finished_at && message.content
|
|
? ''
|
|
: 'inactive'
|
|
"
|
|
>
|
|
<div
|
|
class="w-fit px-4 py-3 rounded-lg max-w-prose shadow bg-white dark:bg-gray-800 border"
|
|
:class="[
|
|
message.role == 'user' ? 'rounded-br-none' : 'rounded-bl-none',
|
|
]"
|
|
>
|
|
<MarkdownRenderer
|
|
v-if="!!message.content"
|
|
:source="message.content"
|
|
/>
|
|
<div
|
|
v-else
|
|
class="flex items-center gap-2 text-foreground/60 text-sm font-medium"
|
|
>
|
|
<Icon
|
|
name="svg-spinners:270-ring-with-bg"
|
|
class="text-lg"
|
|
/>
|
|
思考中
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="!disableUserInput"
|
|
class="relative"
|
|
>
|
|
<Textarea
|
|
class="w-full h-12 pr-24 shadow-lg"
|
|
placeholder="请告诉我额外的补充需求,我会尽量满足您的要求"
|
|
/>
|
|
<div class="absolute right-2 bottom-2">
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
>
|
|
<Icon name="tabler:send" />
|
|
发送
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 表单区域不变 -->
|
|
<div
|
|
v-else
|
|
class="rounded-lg p-6 bg-primary/5"
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<AutoForm
|
|
v-if="formSchema && form"
|
|
:schema="formSchema"
|
|
:form="form"
|
|
:field-config="formFieldConfig"
|
|
class="space-y-2"
|
|
@submit="(values: any) => $emit('submit', values)"
|
|
>
|
|
<div class="w-full flex justify-center gap-2 pt-4">
|
|
<Button
|
|
type="submit"
|
|
size="lg"
|
|
>
|
|
<Icon name="mage:stars-c-fill" />
|
|
一键生成
|
|
</Button>
|
|
</div>
|
|
</AutoForm>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@property --angle {
|
|
syntax: '<angle>';
|
|
inherits: false;
|
|
initial-value: 0deg;
|
|
}
|
|
|
|
.gradient-border {
|
|
position: relative;
|
|
border-radius: 0.5rem;
|
|
padding: 2px;
|
|
overflow: hidden;
|
|
background: conic-gradient(
|
|
from var(--angle),
|
|
hsla(188, 86%, 53%, 0.8),
|
|
hsla(142, 69%, 58%, 0.8),
|
|
hsla(48, 96%, 53%, 0.8),
|
|
hsla(329, 86%, 70%, 0.8),
|
|
hsla(188, 86%, 53%, 0.8)
|
|
);
|
|
animation: rotate 2s linear infinite;
|
|
}
|
|
|
|
.gradient-border.inactive {
|
|
background: transparent;
|
|
}
|
|
|
|
.gradient-border > * {
|
|
border-radius: calc(0.5rem - 2px);
|
|
position: relative;
|
|
}
|
|
|
|
@keyframes rotate {
|
|
to {
|
|
--angle: 360deg;
|
|
}
|
|
}
|
|
</style>
|