-
💬
-
有什么可以帮您的?
-
输入消息开始对话,支持 Markdown、图片、文件等
+
+
+
+
+
+
+
+
一个输入框,完成对话与图片创作
+
选择语言模型或图片生成模型,描述你的需求就可以开始。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
加载中...
-
-
-
-
-
-
-
-
+
+
+
diff --git a/frontend/src/components/NotificationCenter.vue b/frontend/src/components/NotificationCenter.vue
new file mode 100644
index 0000000..b250b97
--- /dev/null
+++ b/frontend/src/components/NotificationCenter.vue
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
{{ item.message }}
+
+
+
+ 查看技术详情
+
+
+
+
{{ item.detail }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index a81fc10..626bc40 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -39,7 +39,7 @@ router.beforeEach(async (to, from, next) => {
return
}
- if (to.meta.guest && auth.isLoggedIn) {
+ if (to.meta.guest && auth.isLoggedIn && !auth.isGuest) {
next({ name: 'Chat' })
return
}
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
index 886883b..8f3af97 100644
--- a/frontend/src/stores/auth.js
+++ b/frontend/src/stores/auth.js
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
+import { getOrCreateGuestKey } from '@/utils/guestSession'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
@@ -8,32 +9,49 @@ export const useAuthStore = defineStore('auth', () => {
const initialized = ref(false)
const isLoggedIn = computed(() => !!token.value && !!user.value)
+ const isGuest = computed(() => !!user.value?.is_guest)
+
+ function setSession(data, type) {
+ token.value = data.token
+ user.value = data.user
+ localStorage.setItem('token', token.value)
+ localStorage.setItem('auth_type', type)
+ }
+
+ async function enterGuestMode() {
+ const res = await api.post('/auth/guest', { guest_key: getOrCreateGuestKey() })
+ setSession(res.data.data, 'guest')
+ return res.data
+ }
async function init() {
- if (token.value) {
- try {
+ try {
+ if (token.value) {
const res = await api.get('/auth/me')
user.value = res.data.data
- } catch {
- logout()
+ localStorage.setItem('auth_type', user.value?.is_guest ? 'guest' : 'account')
}
+
+ if (!user.value) {
+ await enterGuestMode()
+ }
+ } catch {
+ logout()
+ await enterGuestMode()
+ } finally {
+ initialized.value = true
}
- initialized.value = true
}
async function login(account, password) {
const res = await api.post('/auth/login', { account, password })
- token.value = res.data.data.token
- user.value = res.data.data.user
- localStorage.setItem('token', token.value)
+ setSession(res.data.data, 'account')
return res.data
}
async function register(username, email, password) {
const res = await api.post('/auth/register', { username, email, password })
- token.value = res.data.data.token
- user.value = res.data.data.user
- localStorage.setItem('token', token.value)
+ setSession(res.data.data, 'account')
return res.data
}
@@ -41,7 +59,8 @@ export const useAuthStore = defineStore('auth', () => {
token.value = ''
user.value = null
localStorage.removeItem('token')
+ localStorage.removeItem('auth_type')
}
- return { token, user, initialized, isLoggedIn, init, login, register, logout }
+ return { token, user, initialized, isLoggedIn, isGuest, init, enterGuestMode, login, register, logout }
})
diff --git a/frontend/src/stores/chat.js b/frontend/src/stores/chat.js
index c789ada..8f85e1b 100644
--- a/frontend/src/stores/chat.js
+++ b/frontend/src/stores/chat.js
@@ -55,6 +55,7 @@ export const useChatStore = defineStore('chat', () => {
const sidebarOpen = ref(false)
/** 侧边栏当前选中的模型,发送/新建会话都会使用它 */
const selectedModelId = ref(null)
+ const selectedAgentId = ref(localStorage.getItem('selected_agent_id') || null)
let pendingPollTimer = null
let pendingPollInFlight = false
@@ -183,7 +184,16 @@ export const useChatStore = defineStore('chat', () => {
}
}
- async function sendMessage(content, attachments = []) {
+ function setSelectedAgent(agentId) {
+ selectedAgentId.value = agentId || null
+ if (selectedAgentId.value) {
+ localStorage.setItem('selected_agent_id', selectedAgentId.value)
+ } else {
+ localStorage.removeItem('selected_agent_id')
+ }
+ }
+
+ async function sendMessage(content, attachments = [], agentId = selectedAgentId.value) {
if (!currentId.value) {
await createConversation(selectedModelId.value)
} else if (selectedModelId.value != null) {
@@ -207,7 +217,7 @@ export const useChatStore = defineStore('chat', () => {
streamingAttachments.value = []
try {
- await streamChat(content, attachments)
+ await streamChat(content, attachments, agentId)
await loadMessages()
} catch (e) {
// 连接断开/代理超时:任务可能已落库为 pending,先同步再决定是否抛错
@@ -225,7 +235,7 @@ export const useChatStore = defineStore('chat', () => {
}
}
- async function streamChat(content, attachments) {
+ async function streamChat(content, attachments, agentId = null) {
const token = localStorage.getItem('token')
const response = await fetch('/api/chat/completions', {
method: 'POST',
@@ -237,6 +247,7 @@ export const useChatStore = defineStore('chat', () => {
conversation_id: currentId.value,
content,
attachments,
+ agent_id: agentId || null,
stream: true
})
})
@@ -350,10 +361,12 @@ export const useChatStore = defineStore('chat', () => {
streamingAttachments,
sidebarOpen,
selectedModelId,
+ selectedAgentId,
fetchConversations,
createConversation,
selectConversation,
setSelectedModel,
+ setSelectedAgent,
deleteConversation,
sendMessage,
loadMessages,
diff --git a/frontend/src/stores/notification.js b/frontend/src/stores/notification.js
new file mode 100644
index 0000000..1adc105
--- /dev/null
+++ b/frontend/src/stores/notification.js
@@ -0,0 +1,45 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { presentError } from '@/utils/errorPresentation'
+
+let nextId = 1
+
+export const useNotificationStore = defineStore('notification', () => {
+ const notifications = ref([])
+ const timers = new Map()
+
+ function dismiss(id) {
+ const timer = timers.get(id)
+ if (timer) clearTimeout(timer)
+ timers.delete(id)
+ notifications.value = notifications.value.filter(item => item.id !== id)
+ }
+
+ function show(payload) {
+ const id = nextId++
+ const item = {
+ id,
+ type: payload.type || 'error',
+ title: payload.title || '请求失败',
+ message: payload.message || '请求未能完成,请稍后重试。',
+ detail: payload.detail || ''
+ }
+
+ notifications.value = [...notifications.value.slice(-2), item]
+
+ if (payload.duration > 0) {
+ timers.set(id, setTimeout(() => dismiss(id), payload.duration))
+ }
+ return id
+ }
+
+ function error(errorValue, options = {}) {
+ return show({
+ type: 'error',
+ ...presentError(errorValue, options),
+ duration: options.duration ?? 0
+ })
+ }
+
+ return { notifications, show, error, dismiss }
+})
diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js
index d81e6aa..784c59e 100644
--- a/frontend/src/stores/settings.js
+++ b/frontend/src/stores/settings.js
@@ -18,6 +18,7 @@ export const useSettingsStore = defineStore('settings', () => {
const siteName = ref('AI Chat')
const allowRegister = ref(true)
const models = ref([])
+ const agents = ref([])
const loaded = ref(false)
async function loadPublic() {
@@ -34,5 +35,10 @@ export const useSettingsStore = defineStore('settings', () => {
models.value = res.data.data
}
- return { features, siteName, allowRegister, models, loaded, loadPublic, loadModels }
+ async function loadAgents() {
+ const res = await api.get('/agents')
+ agents.value = res.data.data || []
+ }
+
+ return { features, siteName, allowRegister, models, agents, loaded, loadPublic, loadModels, loadAgents }
})
diff --git a/frontend/src/utils/errorPresentation.js b/frontend/src/utils/errorPresentation.js
new file mode 100644
index 0000000..798767e
--- /dev/null
+++ b/frontend/src/utils/errorPresentation.js
@@ -0,0 +1,126 @@
+function toText(value) {
+ if (typeof value === 'string') return value.trim()
+ if (value == null) return ''
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+}
+
+function extractRawError(error) {
+ if (typeof error === 'string') return error.trim()
+ return toText(
+ error?.response?.data?.message
+ || error?.response?.data?.error
+ || error?.detail
+ || error?.message
+ || error
+ ) || '请求未能完成'
+}
+
+function parseEmbeddedJson(raw) {
+ const start = raw.search(/[\[{]/)
+ if (start < 0) return { data: null, prefix: '', formatted: '' }
+
+ const candidate = raw.slice(start).trim()
+ try {
+ const data = JSON.parse(candidate)
+ const prefix = raw.slice(0, start).trim().replace(/[::;;]+$/, '')
+ const formattedJson = JSON.stringify(data, null, 2)
+ return {
+ data,
+ prefix,
+ formatted: prefix ? `${prefix}\n\n${formattedJson}` : formattedJson
+ }
+ } catch {
+ return { data: null, prefix: '', formatted: '' }
+ }
+}
+
+function findDeviceValidation(value) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const match = findDeviceValidation(item)
+ if (match) return match
+ }
+ return null
+ }
+
+ if (!value || typeof value !== 'object') return null
+
+ const info = value.extra_info
+ if (info?.input_name === 'device') {
+ const options = Array.isArray(info.input_config?.[0]) ? info.input_config[0] : []
+ return {
+ received: toText(info.received_value),
+ options: options.map(toText).filter(Boolean)
+ }
+ }
+
+ for (const child of Object.values(value)) {
+ const match = findDeviceValidation(child)
+ if (match) return match
+ }
+ return null
+}
+
+function humanizeComfyError(raw, parsed) {
+ const device = findDeviceValidation(parsed)
+ if (device?.received) {
+ const allowed = device.options.length ? `,当前可用选项为 ${device.options.join('、')}` : ''
+ return `工作流请求使用设备 ${device.received}${allowed}。请修改 ComfyUI 的 device 节点配置后重试。`
+ }
+
+ if (/value_not_in_list|Prompt outputs failed validation/i.test(raw)) {
+ return '工作流参数没有通过 ComfyUI 校验,请检查对应节点、模型和设备配置后重试。'
+ }
+
+ if (/拒绝任务|execution error|执行失败/i.test(raw)) {
+ return 'ComfyUI 拒绝了本次生成任务,请检查工作流和节点配置后重试。'
+ }
+
+ return '图片生成服务暂时无法完成任务,请检查 ComfyUI 配置后重试。'
+}
+
+function defaultTitle(raw, status) {
+ if (/ComfyUI|Prompt outputs failed|value_not_in_list/i.test(raw)) return '图片生成失败'
+ if (status === 401) return '登录状态已失效'
+ if (status === 403) return '没有操作权限'
+ if (/network|Failed to fetch|ERR_NETWORK|连接失败/i.test(raw)) return '连接失败'
+ if (/timeout|超时/i.test(raw)) return '请求超时'
+ return '请求失败'
+}
+
+function defaultMessage(raw, status, parsed) {
+ if (/ComfyUI|Prompt outputs failed|value_not_in_list/i.test(raw)) {
+ return humanizeComfyError(raw, parsed)
+ }
+ if (status === 401) return '登录信息已过期,请重新登录。'
+ if (status === 403) return '当前账户没有执行此操作的权限。'
+ if (/network|Failed to fetch|ERR_NETWORK|连接失败/i.test(raw)) {
+ return '暂时无法连接服务,请检查网络或服务状态后重试。'
+ }
+ if (/timeout|超时/i.test(raw)) return '服务响应时间过长,请稍后重试。'
+
+ const beforeJson = raw.split(/[\[{]/, 1)[0].trim().replace(/[::;;]+$/, '')
+ if (beforeJson && beforeJson.length <= 120) return beforeJson
+ if (raw.length <= 120) return raw
+ return '服务返回了异常信息,请稍后重试或查看技术详情。'
+}
+
+export function presentError(error, options = {}) {
+ const raw = extractRawError(error)
+ const status = error?.status || error?.response?.status || null
+ const embedded = parseEmbeddedJson(raw)
+ const isTechnical = raw.length > 140
+ || !!embedded.formatted
+ || /value_not_in_list|Prompt outputs failed|stack|traceback/i.test(raw)
+
+ return {
+ title: options.title || defaultTitle(raw, status),
+ message: options.message || defaultMessage(raw, status, embedded.data),
+ detail: options.detail ?? (isTechnical ? (embedded.formatted || raw) : ''),
+ status
+ }
+}
diff --git a/frontend/src/utils/guestSession.js b/frontend/src/utils/guestSession.js
new file mode 100644
index 0000000..ca675f3
--- /dev/null
+++ b/frontend/src/utils/guestSession.js
@@ -0,0 +1,17 @@
+const GUEST_KEY_STORAGE = 'guest_key'
+
+export function getGuestKey() {
+ const key = localStorage.getItem(GUEST_KEY_STORAGE) || ''
+ return /^[a-f0-9]{64}$/.test(key) ? key : ''
+}
+
+export function getOrCreateGuestKey() {
+ const existing = getGuestKey()
+ if (existing) return existing
+
+ const bytes = new Uint8Array(32)
+ crypto.getRandomValues(bytes)
+ const key = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
+ localStorage.setItem(GUEST_KEY_STORAGE, key)
+ return key
+}
diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue
index 6675361..db553ae 100644
--- a/frontend/src/views/ChatView.vue
+++ b/frontend/src/views/ChatView.vue
@@ -10,20 +10,35 @@
@@ -45,14 +60,20 @@ import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
+import { useNotificationStore } from '@/stores/notification'
import { useSettingsStore } from '@/stores/settings'
import ChatSidebar from '@/components/ChatSidebar.vue'
import MessageList from '@/components/MessageList.vue'
import ChatInput from '@/components/ChatInput.vue'
+import IconLogout from '@tabler/icons-vue/dist/esm/icons/IconLogout.mjs'
+import IconLogin from '@tabler/icons-vue/dist/esm/icons/IconLogin.mjs'
+import IconMenu2 from '@tabler/icons-vue/dist/esm/icons/IconMenu2.mjs'
+import IconSettings from '@tabler/icons-vue/dist/esm/icons/IconSettings.mjs'
const router = useRouter()
const auth = useAuthStore()
const chat = useChatStore()
+const notification = useNotificationStore()
const settings = useSettingsStore()
const currentTitle = computed(() => {
@@ -63,20 +84,29 @@ const currentTitle = computed(() => {
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
onMounted(async () => {
- await settings.loadPublic()
- await settings.loadModels()
- await chat.fetchConversations()
+ try {
+ await settings.loadPublic()
+ await Promise.all([settings.loadModels(), settings.loadAgents()])
+ await chat.fetchConversations()
+ } catch (err) {
+ notification.error(err, { title: '页面加载失败' })
+ }
})
-async function handleSend({ content, attachments }) {
+async function handleSend({ content, attachments, agentId }) {
try {
- await chat.sendMessage(content, attachments)
+ await chat.sendMessage(content, attachments, agentId)
} catch (e) {
- alert(e.message)
+ notification.error(e)
}
}
-function handleLogout() {
+function handleAccountAction() {
+ if (auth.isGuest) {
+ router.push('/login')
+ return
+ }
+
auth.logout()
router.push('/login')
}
@@ -86,7 +116,9 @@ function handleLogout() {
.chat-layout {
display: flex;
height: 100%;
+ min-height: 100dvh;
overflow: hidden;
+ background: linear-gradient(180deg, #f8faff 0%, var(--bg-primary) 100%);
}
.chat-main {
@@ -94,33 +126,58 @@ function handleLogout() {
display: flex;
flex-direction: column;
min-width: 0;
- background: var(--bg-primary);
+ background: transparent;
}
.chat-header {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 10px;
height: var(--header-height);
- padding: 0 16px;
- border-bottom: 1px solid var(--border);
+ padding: 0 22px;
+ border-bottom: 1px solid rgba(225, 231, 239, 0.92);
+ background: rgba(255, 255, 255, 0.86);
+ backdrop-filter: blur(14px);
flex-shrink: 0;
}
.menu-btn {
display: none;
- padding: 8px;
- border-radius: 8px;
+ width: 36px;
+ height: 36px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
color: var(--text-secondary);
+ transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
}
+
.menu-btn:hover {
- background: var(--bg-hover);
+ background: var(--bg-soft);
+ color: var(--text-primary);
+}
+
+.menu-btn:active {
+ transform: scale(0.96);
+}
+
+.chat-heading {
+ flex: 1;
+ min-width: 0;
+}
+
+.chat-kicker {
+ display: inline-block;
+ margin-bottom: 1px;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
}
.chat-title {
- flex: 1;
font-size: 16px;
- font-weight: 500;
+ font-weight: 650;
+ line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -128,17 +185,47 @@ function handleLogout() {
.header-actions {
display: flex;
- gap: 4px;
+ gap: 6px;
}
.btn-sm {
- padding: 6px 12px;
+ min-height: 36px;
+ padding: 7px 12px;
font-size: 13px;
}
@media (max-width: 768px) {
+ .chat-header {
+ padding: 0 10px 0 8px;
+ }
+
.menu-btn {
display: flex;
}
+
+ .chat-kicker {
+ display: none;
+ }
+
+ .chat-title {
+ font-size: 17px;
+ }
+
+ .btn-sm {
+ width: 36px;
+ min-height: 36px;
+ padding: 0;
+ border-radius: 50%;
+ }
+
+ .action-label {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .menu-btn {
+ transition: none;
+ }
}