Add streaming support and refactor Chat UI

- Add run_stream method to AgentCore for streaming output
- Add base_url parameter to LLM clients for OpenRouter support
- Add xbot module for new agent implementation
- Refactor Chat.vue into composable + components (ChatHeader, ChatMessage, ChatInput, ChatSidebar, ChatAgentSelector)
- Add ChatStream handler for SSE streaming in Go server
- Add UseXBot field to chat request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 10:49:44 +08:00
parent 8062144001
commit 5c435ab21e
31 changed files with 2762 additions and 760 deletions

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { Agent } from '@/composables/useChat'
defineProps<{
show: boolean
selectMode: 'single' | 'group'
chatAgents: Agent[]
selectedAgents: Agent[]
groupChatName: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toggleSelect', agent: Agent): void
(e: 'confirm'): void
(e: 'update:groupChatName', value: string): void
}>()
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="show" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click="emit('close')">
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
<div class="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
</h3>
<p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
<!-- 群聊名称输入框 -->
<div v-if="selectMode === 'group'" class="mb-4">
<input
:value="groupChatName"
@input="emit('update:groupChatName', ($event.target as HTMLInputElement).value)"
type="text"
placeholder="Enter group name..."
class="w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
>
</div>
<div class="space-y-2">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="emit('toggleSelect', agent)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
:class="selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
>
<span class="text-xl">{{ agent.avatar }}</span>
<div class="flex-1 text-left">
<div class="text-white font-medium">{{ agent.name }}</div>
<div class="text-xs text-gray-400">{{ agent.description }}</div>
</div>
<span
v-if="agent.status === 'online'"
class="w-2 h-2 rounded-full bg-emerald-400"
></span>
<span
v-if="selectMode === 'group' && selectedAgents.some(a => a.id === agent.id)"
class="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center"
>
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</span>
</button>
</div>
</div>
<div class="p-4 border-t border-dark-600 flex gap-3">
<button
@click="emit('close')"
class="flex-1 py-2.5 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors"
>
Cancel
</button>
<button
v-if="selectMode === 'group'"
@click="emit('confirm')"
:disabled="selectedAgents.length < 2"
class="flex-1 py-2.5 bg-orange-500 hover:bg-orange-400 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group ({{ selectedAgents.length }})
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { getModelIcon, type Agent, type ChatModel } from '@/composables/useChat'
defineProps<{
selectedAgent: Agent | null
chatModels: ChatModel[]
selectedModel: ChatModel | null
showModelDropdown: boolean
sidebarCollapsed: boolean
}>()
const emit = defineEmits<{
(e: 'toggleDropdown'): void
(e: 'selectModel', model: ChatModel): void
(e: 'toggleSidebar'): void
}>()
</script>
<template>
<div class="h-16 px-4 flex items-center justify-between border-b border-white/[0.06] bg-[#0c0c0f]/80 backdrop-blur-xl">
<!-- 左侧当前AI信息 -->
<div class="flex items-center gap-3">
<div v-if="selectedAgent" class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center text-lg shadow-lg"
:style="{ backgroundColor: selectedAgent.accentColor + '15', color: selectedAgent.accentColor }"
>
{{ selectedAgent.avatar }}
</div>
<div>
<div class="text-sm font-semibold text-white tracking-wide">{{ selectedAgent?.name || 'Chat' }}</div>
<div class="text-[11px] flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-white/40">Online</span>
</div>
</div>
</div>
</div>
<!-- 中间空白 -->
<div class="flex-1"></div>
<!-- 右侧模型选择和折叠按钮 -->
<div class="flex items-center gap-3">
<!-- 模型选择下拉框 -->
<div class="relative model-dropdown" v-if="chatModels.length > 0">
<button
@click="emit('toggleDropdown')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-[#1a1a24] hover:border-orange-500/30 transition-all duration-200"
>
<div class="flex items-center gap-2">
<span class="text-base">{{ getModelIcon(selectedModel?.provider || '') }}</span>
<span class="text-sm text-white">{{ selectedModel?.name || 'Select Model' }}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<svg class="w-3.5 h-3.5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div
v-if="showModelDropdown"
class="absolute right-0 top-full mt-2 w-64 bg-[#1a1a24] border border-white/[0.08] rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
>
<div class="p-2">
<div class="text-xs text-white/40 px-2 py-1 mb-1">Select Chat Model</div>
<button
v-for="model in chatModels"
:key="model.id"
@click="emit('selectModel', model)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-150"
:class="selectedModel?.id === model.id
? 'bg-orange-500/15 border border-orange-500/30'
: 'hover:bg-white/5'"
>
<span class="text-lg">{{ getModelIcon(model.provider) }}</span>
<div class="flex-1 text-left">
<div class="text-sm text-white">{{ model.name }}</div>
<div class="text-xs text-white/40">{{ model.provider }} · {{ model.model }}</div>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
</button>
</div>
</div>
</Transition>
</div>
<!-- 没有模型时的提示 -->
<div v-else class="text-xs text-white/30 px-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/20"></span>
No models
</div>
<button
@click="emit('toggleSidebar')"
class="p-2.5 rounded-xl hover:bg-white/[0.06] text-white/35 hover:text-white/80 transition-all duration-200"
:title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<svg class="w-[18px] h-[18px] transition-transform duration-300" :class="sidebarCollapsed ? '' : 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string
loading: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send'): void
}>()
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
emit('send')
}
}
const autoResize = (e: Event) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 160) + 'px'
}
</script>
<template>
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
<div class="max-w-3xl mx-auto">
<div class="relative bg-[#12121a] rounded-2xl border border-white/[0.08] focus-within:border-orange-500/40 focus-within:shadow-[0_0_30px_rgba(249,115,22,0.08)] transition-all duration-300">
<!-- 附件按钮 -->
<button class="absolute left-4 top-1/2 -translate-y-1/2 text-white/25 hover:text-orange-400 transition-colors p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
</button>
<!-- 输入框 -->
<textarea
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value); autoResize($event)"
@keydown="handleKeydown"
placeholder="发送消息..."
rows="1"
class="w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
></textarea>
<!-- 发送按钮 -->
<button
@click="emit('send')"
:disabled="!modelValue.trim() || loading"
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg flex items-center justify-center transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="modelValue.trim() && !loading
? 'bg-orange-500 hover:bg-orange-400 shadow-lg shadow-orange-500/30 active:scale-90'
: 'bg-white/10'"
>
<svg v-if="!loading" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
<svg v-else class="w-4 h-4 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<!-- 提示 -->
<div class="text-center mt-3">
<span class="text-[10px] text-white/20 tracking-wide">AI 可能会产生错误信息请核实重要内容</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { renderMarkdown, type ChatMessage, type Agent } from '@/composables/useChat'
defineProps<{
message: ChatMessage
selectedAgent: Agent | null
}>()
// 创建消息内容的函数
const getMessageContent = (content: string, isUser: boolean) => {
if (isUser) return content
return renderMarkdown(content)
}
</script>
<template>
<div
class="flex items-end gap-2 mb-4"
:class="message.role === 'user' ? 'flex-row-reverse' : ''"
>
<!-- 头像 -->
<div
class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center"
:class="message.role === 'user' ? 'bg-gradient-to-br from-orange-500 to-amber-600' : ''"
:style="message.role === 'assistant' && selectedAgent ? {
backgroundColor: selectedAgent.accentColor + '20',
color: selectedAgent.accentColor
} : {}"
>
<span v-if="message.role === 'user'" class="text-white text-sm">👤</span>
<span v-else class="text-base">{{ selectedAgent?.avatar || '🧠' }}</span>
</div>
<!-- 消息容器 -->
<div class="flex flex-col max-w-[70%]" :class="message.role === 'user' ? 'items-end' : 'items-start'">
<!-- 消息气泡 -->
<div
class="px-4 py-3 rounded-2xl text-[14px] leading-relaxed markdown-body"
:class="message.role === 'user'
? 'bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-br-sm'
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
>
<span v-html="getMessageContent(message.content, message.role === 'user')"></span>
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span>
</div>
<!-- 时间戳 -->
<span class="text-[11px] text-gray-500 mt-1 px-1">
{{ message.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}
</span>
</div>
</div>
</template>
<style scoped>
.markdown-body {
word-wrap: break-word;
text-wrap: balance;
}
.markdown-body :deep(p) {
margin: 0 0 6px 0;
}
.markdown-body :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-body :deep(code) {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.markdown-body :deep(pre) {
background: rgba(0, 0, 0, 0.4);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
}
.markdown-body :deep(pre code) {
background: transparent;
padding: 0;
}
.markdown-body :deep(ul), .markdown-body :deep(ol) {
margin: 6px 0;
padding-left: 18px;
}
.markdown-body :deep(li) {
margin: 3px 0;
}
.markdown-body :deep(blockquote) {
border-left: 3px solid rgba(255, 255, 255, 0.2);
margin: 6px 0;
padding-left: 10px;
color: rgba(255, 255, 255, 0.6);
}
.markdown-body :deep(a) {
color: #60a5fa;
text-decoration: underline;
}
.markdown-body :deep(h1), .markdown-body :deep(h2), .markdown-body :deep(h3) {
margin: 10px 0 6px 0;
font-weight: 600;
color: #fff;
}
.markdown-body :deep(h1) { font-size: 1.2em; }
.markdown-body :deep(h2) { font-size: 1.1em; }
.markdown-body :deep(h3) { font-size: 1em; }
.markdown-body :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
.markdown-body :deep(th), .markdown-body :deep(td) {
border: 1px solid rgba(255, 255, .2255, 0);
padding: 6px 10px;
text-align: left;
}
.markdown-body :deep(th) {
background: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { Agent, ChatSession, GroupChat } from '@/composables/useChat'
defineProps<{
collapsed: boolean
chatAgents: Agent[]
selectedAgent: Agent | null
chatSessions: ChatSession[]
groupChats: GroupChat[]
}>()
const emit = defineEmits<{
(e: 'openAgentSelector', mode: 'single' | 'group'): void
(e: 'selectAgent', agent: Agent): void
(e: 'selectSession', session: ChatSession): void
}>()
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
</script>
<template>
<div
class="flex-shrink-0 border-l border-white/[0.06] bg-[#0c0c0f] transition-all duration-300 ease-in-out overflow-hidden"
:class="collapsed ? 'w-0 opacity-0' : 'w-72 opacity-100'"
>
<div class="w-72 h-full flex flex-col">
<!-- 侧边栏头部 -->
<div class="p-4 border-b border-white/[0.06]">
<div class="flex items-center gap-2 text-white font-semibold">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<span>AI Hub</span>
</div>
</div>
<!-- 新建对话按钮 -->
<div class="p-3">
<div class="flex gap-2">
<button
@click="emit('openAgentSelector', 'single')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-orange-500 hover:bg-orange-400 rounded-lg text-white text-sm font-medium transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span>新建对话</span>
</button>
<button
@click="emit('openAgentSelector', 'group')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-dark-700 hover:bg-dark-600 border border-dark-500 rounded-lg text-white/80 text-sm font-medium transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span>新建群聊</span>
</button>
</div>
</div>
<!-- AI 助手选择 -->
<div class="px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">选择 AI 助手</div>
<div class="space-y-1">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="emit('selectAgent', agent)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
:class="selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<span class="text-base">{{ agent.avatar }}</span>
<span class="text-sm truncate">{{ agent.name }}</span>
<span
v-if="agent.status === 'online'"
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
></span>
</button>
</div>
</div>
<!-- 群聊列表 -->
<div class="flex-1 overflow-y-auto px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div>
<div class="space-y-1">
<button
v-for="group in groupChats"
:key="group.id"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span>
</div>
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,332 @@
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'
// 类型定义
export interface ChatModel {
id: string
name: string
model_type: string
provider: string
model: string
status: string
}
export interface ChatMessage {
id: number
role: 'user' | 'assistant'
content: string
timestamp: Date
isStreaming?: boolean
}
export interface Agent {
id: number
name: string
avatar: string
description: string
accentColor: string
gradient: string
status: 'online' | 'offline'
}
export interface ChatSession {
id: number
title: string
agentId: number
lastMessage: string
timestamp: Date
}
export interface GroupChat {
id: number
name: string
members: string[]
lastMessage: string
timestamp: Date
}
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true
})
// 预处理内容:修复一些常见的 Markdown 问题
const preprocessContent = (content: string): string => {
if (!content) return ''
// 1. 标题:# 标题 -> # 标题(确保 # 后有空格)
content = content.replace(/(^|\n)(#{1,6})([^\s#\n])/g, '$1$2 $3')
// 2. 无序列表:-项目 -> - 项目
content = content.replace(/(\n)(\s*)([-*+])(\S)/g, '$1$2$3 $4')
// 3. 有序列表1.项目 -> 1. 项目
content = content.replace(/(\n)(\s*)(\d+\.)(\S)/g, '$1$2$3 $4')
// 4. 引用:>引用 -> > 引用
content = content.replace(/(\n)(>+)([^\s>\n])/g, '$1$2 $3')
// 5. 修复 ##1. 这种情况(连续处理)
content = content.replace(/(#{1,6})(\d+\.)/g, '$1 $2')
return content
}
// 渲染 Markdown
export const renderMarkdown = (content: string): string => {
if (!content) return ''
try {
const processed = preprocessContent(content)
return marked.parse(processed) as string
} catch (e) {
console.error('Markdown parse error:', e)
return content
}
}
// 根据 provider 获取图标
export const getModelIcon = (provider: string) => {
const icons: Record<string, string> = {
'OpenAI': '🤖',
'Claude': '🧠',
'Google': '✨',
'Gemini': '✨',
'Ollama': '🦙',
'DeepSeek': '🔮',
'Moonshot': '🌙',
'Kimi': '🌙',
'Baidu': '🐉',
'文心一言': '🐉',
'Aliyun': '☁️',
'Ali': '☁️',
'通义千问': '☁️',
'Azure': '⬛',
'Anthropic': '🧠',
}
return icons[provider] || '💬'
}
// 创建 composable
export function useChat() {
// 模型相关状态
const chatModels = ref<ChatModel[]>([])
const selectedModel = ref<ChatModel | null>(null)
const modelsLoading = ref(false)
const showModelDropdown = ref(false)
// 助手相关状态
const chatAgents = ref<Agent[]>([
{ id: 1, name: 'Claude', avatar: '🧠', description: 'Anthropic AI', accentColor: '#f97316', gradient: 'from-orange-500/20 to-amber-500/20', status: 'online' },
{ id: 2, name: 'Gemini', avatar: '✨', description: 'Google DeepMind', accentColor: '#8b5cf6', gradient: 'from-violet-500/20 to-purple-500/20', status: 'online' },
{ id: 3, name: 'ChatGPT', avatar: '💬', description: 'OpenAI', accentColor: '#10b981', gradient: 'from-emerald-500/20 to-green-500/20', status: 'offline' },
{ id: 4, name: 'DeepSeek', avatar: '🔮', description: 'DeepSeek AI', accentColor: '#3b82f6', gradient: 'from-blue-500/20 to-cyan-500/20', status: 'online' },
{ id: 5, name: 'Kimi', avatar: '🌙', description: 'Moonshot AI', accentColor: '#ec4899', gradient: 'from-pink-500/20 to-rose-500/20', status: 'online' },
{ id: 6, name: '文心一言', avatar: '🐉', description: 'Baidu', accentColor: '#ef4444', gradient: 'from-red-500/20 to-orange-500/20', status: 'offline' },
{ id: 7, name: '通义千问', avatar: '☁️', description: 'Alibaba', accentColor: '#06b6d4', gradient: 'from-cyan-500/20 to-sky-500/20', status: 'online' },
])
const selectedAgent = ref<Agent | null>(chatAgents.value[0])
// 消息相关状态
const messages = ref<ChatMessage[]>([
{ id: 1, role: 'assistant', content: '你好!我是 Claude你的 AI 助手。有什么我可以帮助你的吗?', timestamp: new Date() },
])
// 历史对话
const chatSessions = ref<ChatSession[]>([
{ id: 1, title: '关于 Python 学习的讨论', agentId: 1, lastMessage: '谢谢你!', timestamp: new Date(Date.now() - 3600000) },
{ id: 2, title: '代码调试帮助', agentId: 1, lastMessage: '让我看看这个问题...', timestamp: new Date(Date.now() - 7200000) },
{ id: 3, title: '数据分析咨询', agentId: 4, lastMessage: 'DeepSeek: 好的', timestamp: new Date(Date.now() - 86400000) },
])
// 群聊
const groupChats = ref<GroupChat[]>([
{ id: 1, name: 'AI 讨论组', members: ['Claude', 'GPT-4', 'Gemini'], lastMessage: '我们来讨论一下...', timestamp: new Date(Date.now() - 1800000) },
{ id: 2, name: '编程助手', members: ['Claude', 'DeepSeek'], lastMessage: '这段代码有问题吗?', timestamp: new Date(Date.now() - 3600000) },
{ id: 3, name: '创意头脑风暴', members: ['GPT-4', 'Claude', 'Kimi'], lastMessage: '有个新想法...', timestamp: new Date(Date.now() - 7200000) },
])
// 智能体选择弹窗
const showAgentSelector = ref(false)
const selectMode = ref<'single' | 'group'>('single')
const selectedAgents = ref<Agent[]>([])
const groupChatName = ref('')
// 输入相关
const inputMessage = ref('')
const isLoading = ref(false)
// 侧边栏
const sidebarCollapsed = ref(false)
// 获取模型列表
const fetchModels = async () => {
modelsLoading.value = true
try {
const response = await fetch(`/model/list`)
const data = await response.json()
if (data.list) {
chatModels.value = data.list.filter((m: ChatModel) => m.model_type === 'chat' && m.status === 'active')
if (chatModels.value.length > 0 && !selectedModel.value) {
selectedModel.value = chatModels.value[0]
}
}
} catch (error) {
console.error('Failed to fetch models:', error)
} finally {
modelsLoading.value = false
}
}
// 打开智能体选择器
const openAgentSelector = (mode: 'single' | 'group') => {
selectMode.value = mode
selectedAgents.value = []
groupChatName.value = ''
showAgentSelector.value = true
}
// 切换智能体选择
const toggleAgentSelection = (agent: Agent) => {
const index = selectedAgents.value.findIndex(a => a.id === agent.id)
if (index > -1) {
selectedAgents.value.splice(index, 1)
} else {
selectedAgents.value.push(agent)
}
}
// 确认选择
const confirmAgentSelection = () => {
if (selectMode.value === 'single') {
if (selectedAgents.value.length > 0) {
selectedAgent.value = selectedAgents.value[0]
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
} else {
const name = groupChatName.value.trim() || `群聊 (${selectedAgents.value.length}人)`
groupChats.value.unshift({
id: Date.now(),
name: name,
members: selectedAgents.value.map(a => a.name),
lastMessage: 'New group created',
timestamp: new Date()
})
}
showAgentSelector.value = false
}
// 取消选择
const cancelAgentSelection = () => {
showAgentSelector.value = false
}
// 选择助手
const selectAgent = (agent: Agent) => {
selectedAgent.value = agent
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 选择历史对话
const selectSession = (session: ChatSession) => {
const agent = chatAgents.value.find(a => a.id === session.agentId)
if (agent) {
selectedAgent.value = agent
}
messages.value = [
{ id: 1, role: 'assistant', content: `已加载会话:${session.title}`, timestamp: new Date() }
]
}
// 新建聊天
const newChat = () => {
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value?.name || 'Claude'}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 格式化相对时间
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
// 切换侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 点击外部关闭下拉框
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.model-dropdown')) {
showModelDropdown.value = false
}
}
// 初始化
onMounted(() => {
fetchModels()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
// 模型
chatModels,
selectedModel,
modelsLoading,
showModelDropdown,
fetchModels,
// 助手
chatAgents,
selectedAgent,
selectAgent,
// 消息
messages,
newChat,
// 历史对话
chatSessions,
selectSession,
// 群聊
groupChats,
// 智能体选择
showAgentSelector,
selectMode,
selectedAgents,
groupChatName,
openAgentSelector,
toggleAgentSelection,
confirmAgentSelection,
cancelAgentSelection,
// 输入
inputMessage,
isLoading,
// 侧边栏
sidebarCollapsed,
toggleSidebar,
// 工具
formatTime,
formatRelativeTime,
getModelIcon,
}
}

View File

@@ -19,8 +19,15 @@ const newAgent = ref({
skills: '',
knowledge: '',
prompt: '',
avatar: '🤖',
})
// 头像选项
const avatarOptions = [
'🤖', '🧠', '💻', '📊', '🔬', '🎧', '✨', '💬', '🔮', '🌙',
'🐉', '☁️', '🎨', '🎯', '🚀', '⚡', '🔥', '💡', '🎭', '🎪'
]
// Skills 选项
const skillsOptions = [
{ value: 'research', label: 'Research' },
@@ -41,7 +48,7 @@ const knowledgeOptions = [
// 打开创建弹窗
const openCreateModal = () => {
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '' }
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '', avatar: '🤖' }
showCreateModal.value = true
}
@@ -58,7 +65,7 @@ const createAgent = async () => {
agents.value.unshift({
id: newId,
name: newAgent.value.name,
avatar: '🤖',
avatar: newAgent.value.avatar,
description: newAgent.value.description,
accentColor: '#f97316',
gradient: 'from-orange-500/20 to-amber-500/20',
@@ -259,6 +266,22 @@ const deleteAgent = (id: number) => {
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Avatar</label>
<div class="flex flex-wrap gap-2">
<button
v-for="avatar in avatarOptions"
:key="avatar"
type="button"
@click="newAgent.avatar = avatar"
class="w-10 h-10 rounded-lg flex items-center justify-center text-lg transition-all"
:class="newAgent.avatar === avatar ? 'bg-primary-orange text-white ring-2 ring-orange-400' : 'bg-dark-600 text-gray-300 hover:bg-dark-500'"
>
{{ avatar }}
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Skills *</label>
<el-select v-model="newAgent.skills" placeholder="Select skills" class="w-full" size="large" popper-class="dark-select-dropdown">

View File

@@ -1,215 +1,72 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useChat } from '@/composables/useChat'
import ChatHeader from '@/components/chat/ChatHeader.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatAgentSelector from '@/components/chat/ChatAgentSelector.vue'
const API_BASE = 'http://localhost:8082'
const {
chatModels,
selectedModel,
showModelDropdown,
chatAgents,
selectedAgent,
messages,
chatSessions,
groupChats,
showAgentSelector,
selectMode,
selectedAgents,
groupChatName,
inputMessage,
isLoading,
sidebarCollapsed,
fetchModels,
openAgentSelector,
toggleAgentSelection,
confirmAgentSelection,
cancelAgentSelection,
selectAgent,
selectSession,
newChat,
toggleSidebar,
} = useChat()
// 模型列表
interface ChatModel {
id: string
name: string
model_type: string
provider: string
model: string
status: string
}
const chatModels = ref<ChatModel[]>([])
const selectedModel = ref<ChatModel | null>(null)
const modelsLoading = ref(false)
const showModelDropdown = ref(false)
// 根据 provider 获取图标
const getModelIcon = (provider: string) => {
const icons: Record<string, string> = {
'OpenAI': '🤖',
'Claude': '🧠',
'Google': '✨',
'Gemini': '✨',
'Ollama': '🦙',
'DeepSeek': '🔮',
'Moonshot': '🌙',
'Kimi': '🌙',
'Baidu': '🐉',
'文心一言': '🐉',
'Aliyun': '☁️',
'Ali': '☁️',
'通义千问': '☁️',
'Azure': '⬛',
'Anthropic': '🧠',
}
return icons[provider] || '💬'
}
// 获取模型列表
const fetchModels = async () => {
modelsLoading.value = true
try {
const response = await fetch(`${API_BASE}/model/list`)
const data = await response.json()
if (data.list) {
// 过滤出 chat 类型且 active 状态的模型
chatModels.value = data.list.filter((m: ChatModel) => m.model_type === 'chat' && m.status === 'active')
console.log('Chat models:', chatModels.value)
// 默认选择第一个
if (chatModels.value.length > 0 && !selectedModel.value) {
selectedModel.value = chatModels.value[0]
}
}
} catch (error) {
console.error('Failed to fetch models:', error)
} finally {
modelsLoading.value = false
}
}
onMounted(() => {
fetchModels()
// 点击外部关闭下拉框
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 点击外部关闭下拉框
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.model-dropdown')) {
showModelDropdown.value = false
}
}
interface ChatMessage {
id: number
role: 'user' | 'assistant'
content: string
timestamp: Date
isStreaming?: boolean
}
interface Agent {
id: number
name: string
avatar: string
description: string
accentColor: string
gradient: string
status: 'online' | 'offline'
}
interface ChatSession {
id: number
title: string
agentId: number
lastMessage: string
timestamp: Date
}
// AI 助手配置
const chatAgents = ref<Agent[]>([
{ id: 1, name: 'Claude', avatar: '🧠', description: 'Anthropic AI', accentColor: '#f97316', gradient: 'from-orange-500/20 to-amber-500/20', status: 'online' },
{ id: 2, name: 'Gemini', avatar: '✨', description: 'Google DeepMind', accentColor: '#8b5cf6', gradient: 'from-violet-500/20 to-purple-500/20', status: 'online' },
{ id: 3, name: 'ChatGPT', avatar: '💬', description: 'OpenAI', accentColor: '#10b981', gradient: 'from-emerald-500/20 to-green-500/20', status: 'offline' },
{ id: 4, name: 'DeepSeek', avatar: '🔮', description: 'DeepSeek AI', accentColor: '#3b82f6', gradient: 'from-blue-500/20 to-cyan-500/20', status: 'online' },
{ id: 5, name: 'Kimi', avatar: '🌙', description: 'Moonshot AI', accentColor: '#ec4899', gradient: 'from-pink-500/20 to-rose-500/20', status: 'online' },
{ id: 6, name: '文心一言', avatar: '🐉', description: 'Baidu', accentColor: '#ef4444', gradient: 'from-red-500/20 to-orange-500/20', status: 'offline' },
{ id: 7, name: '通义千问', avatar: '☁️', description: 'Alibaba', accentColor: '#06b6d4', gradient: 'from-cyan-500/20 to-sky-500/20', status: 'online' },
])
// 当前选中的助手
const selectedAgent = ref<Agent | null>(chatAgents.value[0])
// 聊天消息
const messages = ref<ChatMessage[]>([
{ id: 1, role: 'assistant', content: '你好!我是 Claude你的 AI 助手。有什么我可以帮助你的吗?', timestamp: new Date() },
])
// 模拟历史对话列表
const chatSessions = ref<ChatSession[]>([
{ id: 1, title: '关于 Python 学习的讨论', agentId: 1, lastMessage: '谢谢你!', timestamp: new Date(Date.now() - 3600000) },
{ id: 2, title: '代码调试帮助', agentId: 1, lastMessage: '让我看看这个问题...', timestamp: new Date(Date.now() - 7200000) },
{ id: 3, title: '数据分析咨询', agentId: 4, lastMessage: 'DeepSeek: 好的', timestamp: new Date(Date.now() - 86400000) },
])
// 群聊数据
interface GroupChat {
id: number
name: string
members: string[]
lastMessage: string
timestamp: Date
}
const groupChats = ref<GroupChat[]>([
{ id: 1, name: 'AI 讨论组', members: ['Claude', 'GPT-4', 'Gemini'], lastMessage: '我们来讨论一下...', timestamp: new Date(Date.now() - 1800000) },
{ id: 2, name: '编程助手', members: ['Claude', 'DeepSeek'], lastMessage: '这段代码有问题吗?', timestamp: new Date(Date.now() - 3600000) },
{ id: 3, name: '创意头脑风暴', members: ['GPT-4', 'Claude', 'Kimi'], lastMessage: '有个新想法...', timestamp: new Date(Date.now() - 7200000) },
])
// 智能体选择弹窗状态
const showAgentSelector = ref(false)
const selectMode = ref<'single' | 'group'>('single')
const selectedAgents = ref<Agent[]>([])
const groupChatName = ref('')
// 打开智能体选择器
const openAgentSelector = (mode: 'single' | 'group') => {
selectMode.value = mode
selectedAgents.value = []
groupChatName.value = ''
showAgentSelector.value = true
}
// 切换智能体选择(群聊模式)
const toggleAgentSelection = (agent: Agent) => {
const index = selectedAgents.value.findIndex(a => a.id === agent.id)
if (index > -1) {
selectedAgents.value.splice(index, 1)
} else {
selectedAgents.value.push(agent)
}
}
// 确认选择
const confirmAgentSelection = () => {
if (selectMode.value === 'single') {
// 单聊模式:选择一个智能体开始对话
if (selectedAgents.value.length > 0) {
selectedAgent.value = selectedAgents.value[0]
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
} else {
// 群聊模式:选择多个智能体
const name = groupChatName.value.trim() || `群聊 (${selectedAgents.value.length}人)`
console.log('创建群聊:', { name, members: selectedAgents.value })
// 添加到群聊列表
groupChats.value.unshift({
id: Date.now(),
name: name,
members: selectedAgents.value.map(a => a.name),
lastMessage: 'New group created',
timestamp: new Date()
})
}
showAgentSelector.value = false
}
// 取消选择
const cancelAgentSelection = () => {
showAgentSelector.value = false
}
// 侧边栏展开/收起状态
const sidebarCollapsed = ref(false)
// 输入内容
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
// Mock 流式响应(用于测试前端流式效果)
const mockStreamResponse = async (content: string, messageIndex: number) => {
const chars = content.split('')
for (let i = 0; i < chars.length; i++) {
messages.value[messageIndex].content += chars[i]
await nextTick()
scrollToBottom()
await new Promise(resolve => setTimeout(resolve, 15))
}
messages.value[messageIndex].isStreaming = false
isLoading.value = false
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 切换模型下拉框
const toggleModelDropdown = () => {
showModelDropdown.value = !showModelDropdown.value
}
// 选择模型
const handleSelectModel = (model: any) => {
selectedModel.value = model
showModelDropdown.value = false
}
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
@@ -217,17 +74,17 @@ const sendMessage = async () => {
const userContent = inputMessage.value.trim()
inputMessage.value = ''
const userMessage: ChatMessage = {
const userMessage = {
id: Date.now(),
role: 'user',
role: 'user' as const,
content: userContent,
timestamp: new Date()
}
messages.value.push(userMessage)
const aiMessage: ChatMessage = {
const aiMessage = {
id: Date.now() + 1,
role: 'assistant',
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
@@ -238,20 +95,35 @@ const sendMessage = async () => {
isLoading.value = true
// ====== MOCK 模式:使用 mock 测试流式效果 ======
const USE_MOCK = false
if (USE_MOCK) {
const mockResponses = [
"你好!我是你的 AI 助手。",
"我收到了你的消息:\"" + userContent + "\"",
"这是一个模拟的流式响应效果。",
"现在你可以看到文字逐字出现的效果了!"
]
const fullResponse = mockResponses.join(' ')
const aiMessageIndex = messages.value.length - 1
await mockStreamResponse(fullResponse, aiMessageIndex)
return
}
// ====== MOCK 模式结束 ======
try {
// 构建请求体,包含模型信息
const requestBody: any = {
agent_id: selectedAgent.value?.id || 1,
message: userContent,
}
// 如果选择了模型,只传递 model_id后端会根据 id 查询 api_key 和 base_url
if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
// 调用后端 API
const response = await fetch(`${API_BASE}/api/agent/chat`, {
// 先获取完整响应,再逐字符显示(模拟流式效果)
const response = await fetch(`/api/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -259,108 +131,130 @@ const sendMessage = async () => {
body: JSON.stringify(requestBody),
})
const data = await response.json()
const fullResponse = data.reply || data.response || 'No response'
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
// 流式显示回复
let currentIndex = 0
const words = fullResponse.split('')
// 读取完整响应
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
const streamInterval = setInterval(() => {
if (currentIndex < words.length) {
aiMessage.content += words[currentIndex]
currentIndex++
nextTick(() => scrollToBottom())
} else {
clearInterval(streamInterval)
aiMessage.isStreaming = false
isLoading.value = false
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data && data !== '[DONE]') {
fullText += data
}
}
}
}, 30)
}
// 逐字符显示模拟流式每3个字符更新一次UI减少重绘
const aiMessageIndex = messages.value.length - 1
messages.value[aiMessageIndex].content = ''
let tempText = ''
for (let i = 0; i < fullText.length; i++) {
tempText += fullText[i]
// 每3个字符更新一次UI减少重绘次数
if ((i + 1) % 3 === 0 || i === fullText.length - 1) {
messages.value[aiMessageIndex].content = tempText
await nextTick()
scrollToBottom()
// 添加小延迟,让动画更自然
await new Promise(r => setTimeout(r, 8))
}
}
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
scrollToBottom()
} catch (error: any) {
aiMessage.content = `Error: ${error.message || 'Failed to send message'}`
aiMessage.isStreaming = false
console.error('[Stream] 错误:', error)
const errorIndex = messages.value.findIndex(m => m.isStreaming)
if (errorIndex > -1) {
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
messages.value[errorIndex].isStreaming = false
}
isLoading.value = false
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 复制消息
const copyMessage = (content: string) => {
navigator.clipboard.writeText(content)
}
// 选择助手
const selectAgent = (agent: Agent) => {
selectedAgent.value = agent
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 选择历史对话
const selectSession = (session: ChatSession) => {
const agent = chatAgents.value.find(a => a.id === session.agentId)
if (agent) {
selectedAgent.value = agent
}
messages.value = [
{ id: 1, role: 'assistant', content: `已加载会话:${session.title}`, timestamp: new Date() }
]
}
// 新建聊天
const newChat = () => {
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value?.name || 'Claude'}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 格式化相对时间
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
// 回车发送
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
// 调整输入框高度
const autoResize = (e: Event) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 160) + 'px'
}
// 折叠侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script>
<template>
<div class="h-screen flex bg-[#09090b]">
<!-- 主聊天区域 -->
<div class="flex-1 flex flex-col bg-[#09090b]">
<!-- 顶部栏 -->
<ChatHeader
:selected-agent="selectedAgent"
:chat-models="chatModels"
:selected-model="selectedModel"
:show-model-dropdown="showModelDropdown"
:sidebar-collapsed="sidebarCollapsed"
@toggle-dropdown="toggleModelDropdown"
@select-model="handleSelectModel"
@toggle-sidebar="toggleSidebar"
/>
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<div class="px-6">
<ChatMessage
v-for="message in messages"
:key="message.id"
:message="message"
:selected-agent="selectedAgent"
/>
</div>
</div>
<!-- 输入区域 -->
<ChatInput
v-model="inputMessage"
:loading="isLoading"
@send="sendMessage"
/>
</div>
<!-- 右侧边栏 -->
<ChatSidebar
:collapsed="sidebarCollapsed"
:chat-agents="chatAgents"
:selected-agent="selectedAgent"
:chat-sessions="chatSessions"
:group-chats="groupChats"
@open-agent-selector="openAgentSelector"
@select-agent="selectAgent"
@select-session="selectSession"
/>
<!-- 智能体选择弹窗 -->
<ChatAgentSelector
:show="showAgentSelector"
:select-mode="selectMode"
:chat-agents="chatAgents"
:selected-agents="selectedAgents"
:group-chat-name="groupChatName"
@close="cancelAgentSelection"
@toggle-select="toggleAgentSelection"
@confirm="confirmAgentSelection"
@update:group-chat-name="groupChatName = $event"
/>
</div>
</template>
<style scoped>
::-webkit-scrollbar {
width: 6px;
@@ -379,18 +273,6 @@ const toggleSidebar = () => {
background: rgba(255, 255, 255, 0.15);
}
/* 下拉框动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@keyframes messageSlideIn {
from {
opacity: 0;
@@ -424,366 +306,3 @@ const toggleSidebar = () => {
animation: pulse-glow 2s ease-in-out infinite;
}
</style>
<template>
<div class="h-screen flex bg-[#09090b]">
<!-- 主聊天区域 -->
<div class="flex-1 flex flex-col bg-[#09090b]">
<!-- 顶部栏 -->
<div class="h-16 px-4 flex items-center justify-between border-b border-white/[0.06] bg-[#0c0c0f]/80 backdrop-blur-xl">
<!-- 左侧当前AI信息 -->
<div class="flex items-center gap-3">
<div v-if="selectedAgent" class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center text-lg shadow-lg"
:style="{ backgroundColor: selectedAgent.accentColor + '15', color: selectedAgent.accentColor }"
>
{{ selectedAgent.avatar }}
</div>
<div>
<div class="text-sm font-semibold text-white tracking-wide">{{ selectedAgent?.name || 'Chat' }}</div>
<div class="text-[11px] flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-white/40">Online</span>
</div>
</div>
</div>
</div>
<!-- 中间空白 -->
<div class="flex-1"></div>
<!-- 右侧模型选择和折叠按钮 -->
<div class="flex items-center gap-3">
<!-- 模型选择下拉框 -->
<div class="relative model-dropdown" v-if="chatModels.length > 0">
<button
@click="showModelDropdown = !showModelDropdown"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-[#1a1a24] hover:border-orange-500/30 transition-all duration-200"
>
<div class="flex items-center gap-2">
<!-- 动态获取模型图标 -->
<span class="text-base">{{ getModelIcon(selectedModel?.provider || '') }}</span>
<span class="text-sm text-white">{{ selectedModel?.name || 'Select Model' }}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<svg class="w-3.5 h-3.5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div
v-if="showModelDropdown"
class="absolute right-0 top-full mt-2 w-64 bg-[#1a1a24] border border-white/[0.08] rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
>
<div class="p-2">
<div class="text-xs text-white/40 px-2 py-1 mb-1">Select Chat Model</div>
<button
v-for="model in chatModels"
:key="model.id"
@click="selectedModel = model; showModelDropdown = false"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-150"
:class="selectedModel?.id === model.id
? 'bg-orange-500/15 border border-orange-500/30'
: 'hover:bg-white/5'"
>
<span class="text-lg">{{ getModelIcon(model.provider) }}</span>
<div class="flex-1 text-left">
<div class="text-sm text-white">{{ model.name }}</div>
<div class="text-xs text-white/40">{{ model.provider }} · {{ model.model }}</div>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
</button>
</div>
</div>
</Transition>
</div>
<!-- 没有模型时的提示 -->
<div v-else class="text-xs text-white/30 px-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/20"></span>
No models
</div>
<button
@click="toggleSidebar"
class="p-2.5 rounded-xl hover:bg-white/[0.06] text-white/35 hover:text-white/80 transition-all duration-200"
:title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<svg class="w-[18px] h-[18px] transition-transform duration-300" :class="sidebarCollapsed ? '' : 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<div class="px-6">
<div
v-for="message in messages"
:key="message.id"
class="message-enter flex items-start mb-4"
:class="message.role === 'user' ? 'flex-row-reverse' : ''"
>
<!-- 头像 -->
<div
class="w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center mx-3 mt-1"
:class="message.role === 'user' ? 'bg-gradient-to-br from-orange-500 to-amber-600' : ''"
:style="message.role === 'assistant' && selectedAgent ? {
backgroundColor: selectedAgent.accentColor + '25',
color: selectedAgent.accentColor
} : {}"
>
<span v-if="message.role === 'user'" class="text-white text-sm">👤</span>
<span v-else class="text-lg">{{ selectedAgent?.avatar || '🧠' }}</span>
</div>
<!-- 气泡和时间戳容器 -->
<div :class="message.role === 'user' ? 'mr-3 ml-auto' : 'ml-3'">
<!-- 消息气泡 -->
<div
class="px-4 py-2.5 rounded-xl text-[14px] leading-6"
:class="message.role === 'user'
? 'bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-tr-sm'
: 'bg-[#2a2a35] text-white/90 rounded-tl-sm max-w-[80%]'"
>
{{ message.content }}
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span>
</div>
<!-- 时间戳 -->
<div
class="text-[11px] text-white/30 mt-1"
:class="message.role === 'user' ? 'text-right' : ''"
>
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
<div class="max-w-3xl mx-auto">
<div class="relative bg-[#12121a] rounded-2xl border border-white/[0.08] focus-within:border-orange-500/40 focus-within:shadow-[0_0_30px_rgba(249,115,22,0.08)] transition-all duration-300">
<!-- 附件按钮 -->
<button class="absolute left-4 top-1/2 -translate-y-1/2 text-white/25 hover:text-orange-400 transition-colors p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
</button>
<!-- 输入框 -->
<textarea
v-model="inputMessage"
@keydown="handleKeydown"
@input="autoResize"
placeholder="发送消息..."
rows="1"
class="w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
></textarea>
<!-- 发送按钮 -->
<button
@click="sendMessage"
:disabled="!inputMessage.trim() || isLoading"
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg flex items-center justify-center transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="inputMessage.trim() && !isLoading
? 'bg-orange-500 hover:bg-orange-400 shadow-lg shadow-orange-500/30 active:scale-90'
: 'bg-white/10'"
>
<svg v-if="!isLoading" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
<svg v-else class="w-4 h-4 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<!-- 提示 -->
<div class="text-center mt-3">
<span class="text-[10px] text-white/20 tracking-wide">AI 可能会产生错误信息请核实重要内容</span>
</div>
</div>
</div>
</div>
<!-- 右侧边栏AI Hub -->
<div
class="flex-shrink-0 border-l border-white/[0.06] bg-[#0c0c0f] transition-all duration-300 ease-in-out overflow-hidden"
:class="sidebarCollapsed ? 'w-0 opacity-0' : 'w-72 opacity-100'"
>
<div class="w-72 h-full flex flex-col">
<!-- 侧边栏头部 -->
<div class="p-4 border-b border-white/[0.06]">
<div class="flex items-center gap-2 text-white font-semibold">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<span>AI Hub</span>
</div>
</div>
<!-- 新建对话按钮 -->
<div class="p-3">
<div class="flex gap-2">
<button
@click="openAgentSelector('single')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-orange-500 hover:bg-orange-400 rounded-lg text-white text-sm font-medium transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span>新建对话</span>
</button>
<button
@click="openAgentSelector('group')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-dark-700 hover:bg-dark-600 border border-dark-500 rounded-lg text-white/80 text-sm font-medium transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span>新建群聊</span>
</button>
</div>
</div>
<!-- AI 助手选择 -->
<div class="px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">选择 AI 助手</div>
<div class="space-y-1">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="selectAgent(agent)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
:class="selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<span class="text-base">{{ agent.avatar }}</span>
<span class="text-sm truncate">{{ agent.name }}</span>
<span
v-if="agent.status === 'online'"
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
></span>
</button>
</div>
</div>
<!-- 群聊列表 -->
<div class="flex-1 overflow-y-auto px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div>
<div class="space-y-1">
<button
v-for="group in groupChats"
:key="group.id"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span>
</div>
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 智能体选择弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showAgentSelector" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click="cancelAgentSelection">
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
<div class="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
</h3>
<p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
<!-- 群聊名称输入框 -->
<div v-if="selectMode === 'group'" class="mb-4">
<input
v-model="groupChatName"
type="text"
placeholder="Enter group name..."
class="w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
>
</div>
<div class="space-y-2">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="selectMode === 'group' ? toggleAgentSelection(agent) : (selectedAgents = [agent], confirmAgentSelection())"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
:class="selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
>
<span class="text-xl">{{ agent.avatar }}</span>
<div class="flex-1 text-left">
<div class="text-white font-medium">{{ agent.name }}</div>
<div class="text-xs text-gray-400">{{ agent.description }}</div>
</div>
<span
v-if="agent.status === 'online'"
class="w-2 h-2 rounded-full bg-emerald-400"
></span>
<span
v-if="selectMode === 'group' && selectedAgents.some(a => a.id === agent.id)"
class="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center"
>
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</span>
</button>
</div>
</div>
<div class="p-4 border-t border-dark-600 flex gap-3">
<button
@click="cancelAgentSelection"
class="flex-1 py-2.5 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors"
>
Cancel
</button>
<button
v-if="selectMode === 'group'"
@click="confirmAgentSelection"
:disabled="selectedAgents.length < 2"
class="flex-1 py-2.5 bg-orange-500 hover:bg-orange-400 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group ({{ selectedAgents.length }})
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useSkills } from './skill/useSkills'
import { Edit, Trash2, Wand2, Plus, Search, X } from 'lucide-vue-next'
import { Edit, Trash2, Wand2, Plus, Search, X, FolderInput } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import '@/views/database/database.css'
const {
@@ -23,10 +24,91 @@ const {
deleteSkill,
} = useSkills()
// 从本地导入
const fileInputRef = ref<HTMLInputElement | null>(null)
const isImporting = ref(false)
// 触发文件夹选择
const triggerImport = () => {
fileInputRef.value?.click()
}
// 处理文件夹选择
const handleFolderSelect = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
isImporting.value = true
try {
// 获取第一个文件夹
const folder = files[0].webkitRelativePath?.split('/')[0] || files[0].name
console.log('选择的文件夹:', folder)
// 查找 SKILL.md 文件
let skillMdFile: File | null = null
for (let i = 0; i < files.length; i++) {
const file = files[i]
// 检查是否是 SKILL.md 文件
if (file.name === 'SKILL.md' || file.webkitRelativePath?.endsWith('/SKILL.md')) {
skillMdFile = file
break
}
}
if (!skillMdFile) {
ElMessage.error('导入失败:所选文件夹中未找到 SKILL.md 文件,请确保选择包含 SKILL.md 的文件夹')
return
}
// 读取 SKILL.md 内容
const content = await skillMdFile.text()
console.log('SKILL.md 内容:', content.substring(0, 100))
// 解析 SKILL.md 内容
// 格式:第一行是 skill_name后面是 skill_desc
const lines = content.trim().split('\n')
const skillName = lines[0]?.replace(/^#\s*/, '').trim() || folder
const skillDesc = lines.slice(1).join('\n').trim()
// 调用保存接口
const response = await fetch('http://localhost:8082/skill/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skill_name: skillName,
skill_desc: skillDesc,
skill_type: 'user',
status: 'active'
})
})
if (response.ok) {
ElMessage.success(`Skill imported: ${skillName}`)
fetchSkills()
} else {
const err = await response.json()
ElMessage.error(err.message || '导入失败')
}
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('导入失败,请检查文件夹格式是否正确')
} finally {
isImporting.value = false
// 清空 input 以便重新选择同一文件夹
input.value = ''
}
}
// 页面加载时获取技能列表
onMounted(() => {
fetchSkills()
})
// 下拉菜单显示状态
const showDropdown = ref(false)
</script>
<template>
@@ -37,10 +119,51 @@ onMounted(() => {
<Wand2 class="w-5 h-5 text-orange-500" />
<span class="font-medium">Skills</span>
</div>
<button @click="openCreate" class="btn-primary">
<Plus class="w-4 h-4" />
New Skill
</button>
<!-- 新建按钮带下拉菜单 -->
<div class="relative" @mouseenter="showDropdown = true" @mouseleave="showDropdown = false">
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white text-sm font-medium hover:from-orange-400 hover:to-amber-400 transition-all">
<Plus class="w-4 h-4" />
New Skill
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<div v-if="showDropdown" class="absolute right-0 top-full mt-2 w-48 bg-dark-700 border border-dark-500 rounded-lg shadow-xl overflow-hidden z-50">
<button
@click="openCreate"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left"
>
<Plus class="w-4 h-4 text-orange-400" />
<div>
<div class="text-sm text-white">Create Skill</div>
<div class="text-xs text-gray-400">Manual create</div>
</div>
</button>
<button
@click="triggerImport"
:disabled="isImporting"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left disabled:opacity-50"
>
<FolderInput class="w-4 h-4 text-blue-400" />
<div>
<div class="text-sm text-white">Import Skill</div>
<div class="text-xs text-gray-400">From local folder</div>
</div>
</button>
</div>
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
webkitdirectory=""
directory=""
multiple
style="display: none"
@change="handleFolderSelect"
/>
</div>
<!-- 搜索和筛选 -->

View File

@@ -165,6 +165,7 @@ export function useModelSettings() {
body: JSON.stringify({
provider: editForm.value.provider,
model: editForm.value.model,
model_type: editForm.value.modelType || 'chat',
api_key: editForm.value.apiKey,
base_url: editForm.value.baseUrl,
api_endpoint: editForm.value.apiEndpoint,

View File

@@ -5,3 +5,11 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}