添加ai支持

This commit is contained in:
2026-06-10 16:04:42 +08:00
parent 51f3f917f9
commit cd03cdc44a
15 changed files with 2159 additions and 3 deletions
+108
View File
@@ -0,0 +1,108 @@
import { api } from './index'
import { useUserStore } from '@/stores/user'
export async function fetchOpenAIProfiles() {
return api.get('/aichat/openai')
}
export async function fetchAIChatAdminConfig() {
return api.post('/aichat/admin/config', {})
}
export async function updateAIChatAdminConfig(config) {
return api.post('/aichat/admin/config/update', config)
}
export async function refreshAIChatAdminConfig() {
return api.post('/aichat/admin/refresh', {})
}
function parseSSEBlock(block) {
const lines = block.split('\n')
const dataLines = []
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) return null
return dataLines.join('\n')
}
export async function streamChat(messages, options = {}, handlers = {}) {
if (typeof options.onDelta === 'function' || typeof options.onError === 'function') {
handlers = options
options = {}
}
const userStore = useUserStore()
const response = await fetch('/api/aichat/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userCookieValue: userStore.cookieValue || '',
data: { messages, openaiName: options.openaiName || '' },
}),
})
if (!response.ok) {
handlers.onError?.(`HTTP ${response.status}`)
return
}
if (!response.body) {
handlers.onError?.('ReadableStream is not supported')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
const payload = parseSSEBlock(block)
if (!payload) continue
if (payload === '[DONE]') {
handlers.onDone?.()
return
}
try {
const frame = JSON.parse(payload)
switch (frame.type) {
case 'delta':
handlers.onDelta?.(frame.text || '')
break
case 'trace':
handlers.onTrace?.(frame)
break
case 'stats':
handlers.onStats?.(frame.stats || null)
break
case 'error':
handlers.onError?.(frame.error || frame.message || 'AI request failed')
break
case '[DONE]':
handlers.onDone?.()
return
default:
handlers.onFrame?.(frame)
break
}
} catch (error) {
handlers.onError?.(error.message)
}
}
}
handlers.onDone?.()
}
@@ -58,6 +58,7 @@ const navItems = computed(() => [
{ label: t("appname.purchase"), to: "/purchase" },
{ label: t("appname.work_order"), to: "/work_order" },
{ label: t("appname.warehouse"), to: "/warehouse" },
{ label: t("appname.aichat"), to: "/aichat" },
]);
</script>
+60 -1
View File
@@ -36,7 +36,66 @@
"warehouse": "Warehouse",
"warehouse_items": "Items Overview",
"work_order": "Work Order",
"calendar": "Calendar"
"calendar": "Calendar",
"aichat": "AI Chat"
},
"aichat": {
"title": "AI Chat",
"subtitle": "Chat with the AI assistant using streaming responses and backend tool traces.",
"empty_title": "Start an AI chat",
"empty_hint": "Type a question and press Enter to send. Use Shift + Enter for a new line.",
"input_placeholder": "Type a message...",
"send": "Send",
"clear": "Clear",
"thinking": "Thinking...",
"streaming": "Generating response",
"tokens": "Token usage",
"profile": "AI profile",
"default_profile": "Default profile",
"tool_router": "Tool router",
"enter_hint": "Enter to send, Shift + Enter for a new line",
"error_prefix": "Request failed: "
},
"aiconfig": {
"title": "AI Config",
"subtitle": "Configure AI profiles, tool routing, and tool list stored in the database.",
"reload": "Reload",
"refresh_cache": "Refresh Cache",
"save": "Save",
"enabled": "Enabled",
"profiles": "AI Profiles",
"add_profile": "Add Profile",
"remove": "Remove",
"active": "Default Profile",
"name": "Name",
"api_key": "API Key",
"api_key_keep": "Already configured; leave blank to keep it",
"base_url": "Base URL",
"model": "Model",
"timeout": "Timeout (seconds)",
"max_tokens": "Max Tokens",
"system_prompt": "System Prompt",
"tool_router": "Tool Router",
"router_profile": "Router Profile",
"none": "None",
"tools": "Tools",
"add_tool": "Add Tool",
"description": "Description",
"load_failed": "Failed to load AI config",
"save_success": "AI config saved",
"save_failed": "Failed to save AI config",
"refresh_success": "AI config cache refreshed",
"refresh_failed": "Failed to refresh AI config cache",
"error_profile_required": "At least one AI profile is required",
"error_profile_name_required": "Profile name is required",
"error_profile_name_duplicate": "Profile names must be unique",
"error_active_profile_required": "When AI is enabled, the default profile must have Base URL and model configured",
"error_api_key_required": "When AI is enabled, the default profile must have an API key",
"error_timeout": "Timeout must be a positive integer",
"error_max_tokens": "Max tokens must be a positive integer",
"error_router_profile": "Tool router profile must exist in profile list",
"error_tool_name_required": "Tool name is required",
"error_tool_name_duplicate": "Tool names must be unique"
},
"tagadder": {
"not_fund_item": "No matching items found",
+60 -1
View File
@@ -36,7 +36,66 @@
"warehouse": "仓库",
"warehouse_items": "物品总览",
"work_order": "工单",
"calendar": "日历"
"calendar": "日历",
"aichat": "AI 对话"
},
"aichat": {
"title": "AI 对话",
"subtitle": "通过流式响应与 AI 助手对话,可显示后端工具执行过程。",
"empty_title": "开始一次 AI 对话",
"empty_hint": "输入问题后按 Enter 发送,Shift + Enter 换行。",
"input_placeholder": "输入消息...",
"send": "发送",
"clear": "清空",
"thinking": "正在思考...",
"streaming": "正在生成回复",
"tokens": "Token 用量",
"profile": "AI 接口",
"default_profile": "默认接口",
"tool_router": "工具路由",
"enter_hint": "Enter 发送,Shift + Enter 换行",
"error_prefix": "请求失败:"
},
"aiconfig": {
"title": "AI 配置",
"subtitle": "配置数据库中的 AI 接口、工具路由和工具列表。",
"reload": "重新加载",
"refresh_cache": "刷新缓存",
"save": "保存",
"enabled": "启用",
"profiles": "AI 接口 Profiles",
"add_profile": "添加接口",
"remove": "删除",
"active": "默认接口",
"name": "名称",
"api_key": "API Key",
"api_key_keep": "已配置,留空保留原密钥",
"base_url": "Base URL",
"model": "模型",
"timeout": "超时(秒)",
"max_tokens": "最大 Token",
"system_prompt": "系统提示词",
"tool_router": "工具路由",
"router_profile": "路由接口",
"none": "不指定",
"tools": "工具",
"add_tool": "添加工具",
"description": "描述",
"load_failed": "加载 AI 配置失败",
"save_success": "AI 配置已保存",
"save_failed": "保存 AI 配置失败",
"refresh_success": "AI 配置缓存已刷新",
"refresh_failed": "刷新 AI 配置缓存失败",
"error_profile_required": "至少需要一个 AI 接口",
"error_profile_name_required": "接口名称不能为空",
"error_profile_name_duplicate": "接口名称不能重复",
"error_active_profile_required": "启用 AI 时默认接口必须配置 Base URL 和模型",
"error_api_key_required": "启用 AI 时默认接口必须配置 API Key",
"error_timeout": "超时必须是正整数",
"error_max_tokens": "最大 Token 必须是正整数",
"error_router_profile": "工具路由接口必须来自现有接口列表",
"error_tool_name_required": "工具名称不能为空",
"error_tool_name_duplicate": "工具名称不能重复"
},
"tagadder": {
"not_fund_item": "没有找到匹配项",
+12 -1
View File
@@ -109,6 +109,12 @@ const router = createRouter({
component: () => import('@/views/sysadmin/SysAdminView.vue'),
meta: { requireSysAdmin: true },
},
{
path: 'admin/aiconfig',
name: 'admin-aiconfig',
component: () => import('@/views/admin/AIConfigView.vue'),
meta: { requireSysAdmin: true },
},
{
path: 'customer',
name: 'customer',
@@ -150,6 +156,11 @@ const router = createRouter({
name: 'user-my',
component: () => import('@/views/user/MyProfile.vue'),
},
{
path: 'aichat',
name: 'aichat',
component: () => import('@/views/aichat/AiChatView.vue'),
},
],
},
@@ -206,7 +217,7 @@ router.beforeEach((to) => {
const userStore = useUserStore()
// 不需要登录的页面(精确匹配或前缀匹配)
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/404']
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/aichat', '/404']
const publicPrefixes = ['/calendar/']
if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true
@@ -0,0 +1,513 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconDeviceFloppy, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue'
import { fetchAIChatAdminConfig, refreshAIChatAdminConfig, updateAIChatAdminConfig } from '@/api/aichat'
import { usePageTitle } from '@/composables/usePageTitle'
import { useToastStore } from '@/stores/toast'
const { t } = useI18n()
const toast = useToastStore()
usePageTitle('aiconfig.title')
const loading = ref(false)
const saving = ref(false)
const refreshing = ref(false)
const form = reactive({
enabled: false,
openai: [],
toolRouter: {
enabled: true,
openaiName: '',
timeout: 30,
maxTokens: 512,
tools: [],
},
})
const profileNames = computed(() => form.openai.map((profile) => profile.name.trim()).filter(Boolean))
const activeProfileName = computed(() => form.openai.find((profile) => profile.active)?.name || form.openai[0]?.name || '')
onMounted(loadConfig)
function normalizeProfile(profile = {}) {
return {
name: profile.name || '',
active: !!profile.active,
apiKey: '',
apiKeySet: !!profile.apiKeySet,
baseUrl: profile.baseUrl || '',
model: profile.model || '',
timeout: Number(profile.timeout || 120),
maxTokens: Number(profile.maxTokens || 4096),
systemPrompt: profile.systemPrompt || '',
}
}
function normalizeTool(tool = {}) {
return {
name: tool.name || '',
enabled: tool.enabled !== false,
description: tool.description || '',
}
}
function applyConfig(data) {
form.enabled = !!data?.enabled
form.openai = Array.isArray(data?.openai) ? data.openai.map(normalizeProfile) : []
if (form.openai.length === 0) {
addProfile()
}
const router = data?.toolRouter || {}
form.toolRouter = {
enabled: router.enabled !== false,
openaiName: router.openaiName || '',
timeout: Number(router.timeout || 30),
maxTokens: Number(router.maxTokens || 512),
tools: Array.isArray(router.tools) ? router.tools.map(normalizeTool) : [],
}
if (form.toolRouter.tools.length === 0) {
addTool()
}
}
async function loadConfig() {
loading.value = true
try {
const res = await fetchAIChatAdminConfig()
if (res.errCode === 0) {
applyConfig(res.data || {})
} else {
toast.error(t('aiconfig.load_failed'))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
loading.value = false
}
}
async function refreshCache() {
refreshing.value = true
try {
const res = await refreshAIChatAdminConfig()
if (res.errCode === 0) {
toast.success(t('aiconfig.refresh_success'))
await loadConfig()
} else {
toast.error(t('aiconfig.refresh_failed'))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
refreshing.value = false
}
}
function addProfile() {
form.openai.push({
name: '',
active: form.openai.length === 0,
apiKey: '',
apiKeySet: false,
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
model: '',
timeout: 120,
maxTokens: 4096,
systemPrompt: '',
})
}
function removeProfile(index) {
const removed = form.openai[index]
form.openai.splice(index, 1)
if (form.openai.length === 0) {
addProfile()
}
if (!form.openai.some((profile) => profile.active)) {
form.openai[0].active = true
}
if (removed?.name && form.toolRouter.openaiName === removed.name) {
form.toolRouter.openaiName = activeProfileName.value
}
}
function setActiveProfile(index) {
form.openai.forEach((profile, i) => {
profile.active = i === index
})
}
function addTool() {
form.toolRouter.tools.push({ name: '', enabled: true, description: '' })
}
function removeTool(index) {
form.toolRouter.tools.splice(index, 1)
}
function validate() {
if (form.openai.length === 0) {
toast.error(t('aiconfig.error_profile_required'))
return false
}
const names = new Set()
let hasActive = false
for (const profile of form.openai) {
profile.name = profile.name.trim()
profile.baseUrl = profile.baseUrl.trim()
profile.model = profile.model.trim()
profile.apiKey = profile.apiKey.trim()
if (!profile.name) {
toast.error(t('aiconfig.error_profile_name_required'))
return false
}
if (names.has(profile.name)) {
toast.error(t('aiconfig.error_profile_name_duplicate'))
return false
}
names.add(profile.name)
if (profile.active) {
hasActive = true
}
profile.timeout = Number(profile.timeout)
profile.maxTokens = Number(profile.maxTokens)
if (!Number.isInteger(profile.timeout) || profile.timeout <= 0) {
toast.error(t('aiconfig.error_timeout'))
return false
}
if (!Number.isInteger(profile.maxTokens) || profile.maxTokens <= 0) {
toast.error(t('aiconfig.error_max_tokens'))
return false
}
}
if (!hasActive) {
form.openai[0].active = true
}
const active = form.openai.find((profile) => profile.active)
if (form.enabled && active) {
if (!active.baseUrl || !active.model) {
toast.error(t('aiconfig.error_active_profile_required'))
return false
}
if (!active.apiKeySet && !active.apiKey) {
toast.error(t('aiconfig.error_api_key_required'))
return false
}
}
if (form.toolRouter.openaiName && !names.has(form.toolRouter.openaiName)) {
toast.error(t('aiconfig.error_router_profile'))
return false
}
form.toolRouter.timeout = Number(form.toolRouter.timeout)
form.toolRouter.maxTokens = Number(form.toolRouter.maxTokens)
if (!Number.isInteger(form.toolRouter.timeout) || form.toolRouter.timeout <= 0) {
toast.error(t('aiconfig.error_timeout'))
return false
}
if (!Number.isInteger(form.toolRouter.maxTokens) || form.toolRouter.maxTokens <= 0) {
toast.error(t('aiconfig.error_max_tokens'))
return false
}
const toolNames = new Set()
for (const tool of form.toolRouter.tools) {
tool.name = tool.name.trim()
if (!tool.name) {
toast.error(t('aiconfig.error_tool_name_required'))
return false
}
if (toolNames.has(tool.name)) {
toast.error(t('aiconfig.error_tool_name_duplicate'))
return false
}
toolNames.add(tool.name)
}
return true
}
function buildPayload() {
return {
enabled: form.enabled,
openai: form.openai.map((profile) => ({
name: profile.name.trim(),
active: !!profile.active,
apiKey: profile.apiKey.trim(),
baseUrl: profile.baseUrl.trim(),
model: profile.model.trim(),
timeout: Number(profile.timeout),
maxTokens: Number(profile.maxTokens),
systemPrompt: profile.systemPrompt || '',
})),
toolRouter: {
enabled: !!form.toolRouter.enabled,
openaiName: form.toolRouter.openaiName || '',
timeout: Number(form.toolRouter.timeout),
maxTokens: Number(form.toolRouter.maxTokens),
tools: form.toolRouter.tools.map((tool) => ({
name: tool.name.trim(),
enabled: !!tool.enabled,
description: tool.description || '',
})),
},
}
}
async function saveConfig() {
if (!validate()) return
saving.value = true
try {
const res = await updateAIChatAdminConfig(buildPayload())
if (res.errCode === 0) {
toast.success(t('aiconfig.save_success'))
await loadConfig()
} else {
toast.error(t('aiconfig.save_failed'))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
saving.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
<div class="mx-auto max-w-6xl space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('aiconfig.title') }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">{{ t('aiconfig.subtitle') }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button class="btn-secondary" :disabled="loading" @click="loadConfig">
<IconRefresh :size="16" />
{{ t('aiconfig.reload') }}
</button>
<button class="btn-secondary" :disabled="refreshing" @click="refreshCache">
<IconRefresh :size="16" />
{{ t('aiconfig.refresh_cache') }}
</button>
<button class="btn-primary" :disabled="saving" @click="saveConfig">
<IconDeviceFloppy :size="16" />
{{ t('aiconfig.save') }}
</button>
</div>
</div>
<section class="card">
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text">
<input v-model="form.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
{{ t('aiconfig.enabled') }}
</label>
</section>
<section class="card space-y-4">
<div class="flex items-center justify-between">
<h2 class="section-title">{{ t('aiconfig.profiles') }}</h2>
<button class="btn-secondary" @click="addProfile">
<IconPlus :size="16" />
{{ t('aiconfig.add_profile') }}
</button>
</div>
<div v-for="(profile, index) in form.openai" :key="index" class="rounded-lg border border-gray-200 p-4 dark:border-dk-muted">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dk-text">
<input :checked="profile.active" type="radio" name="active-profile" class="text-blue-600" @change="setActiveProfile(index)" />
{{ t('aiconfig.active') }}
</label>
<button class="text-sm text-red-600 hover:text-red-700" @click="removeProfile(index)">
<IconTrash :size="16" class="inline" />
{{ t('aiconfig.remove') }}
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="field">
<span>{{ t('aiconfig.name') }}</span>
<input v-model="profile.name" class="input" />
</label>
<label class="field">
<span>{{ t('aiconfig.api_key') }}</span>
<input v-model="profile.apiKey" class="input" type="password" :placeholder="profile.apiKeySet ? t('aiconfig.api_key_keep') : ''" />
</label>
<label class="field md:col-span-2">
<span>{{ t('aiconfig.base_url') }}</span>
<input v-model="profile.baseUrl" class="input" />
</label>
<label class="field">
<span>{{ t('aiconfig.model') }}</span>
<input v-model="profile.model" class="input" />
</label>
<label class="field">
<span>{{ t('aiconfig.timeout') }}</span>
<input v-model.number="profile.timeout" class="input" type="number" min="1" />
</label>
<label class="field">
<span>{{ t('aiconfig.max_tokens') }}</span>
<input v-model.number="profile.maxTokens" class="input" type="number" min="1" />
</label>
<label class="field md:col-span-2">
<span>{{ t('aiconfig.system_prompt') }}</span>
<textarea v-model="profile.systemPrompt" class="input min-h-24 resize-y" />
</label>
</div>
</div>
</section>
<section class="card space-y-4">
<h2 class="section-title">{{ t('aiconfig.tool_router') }}</h2>
<div class="grid gap-4 md:grid-cols-4">
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text">
<input v-model="form.toolRouter.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
{{ t('aiconfig.enabled') }}
</label>
<label class="field md:col-span-1">
<span>{{ t('aiconfig.router_profile') }}</span>
<select v-model="form.toolRouter.openaiName" class="input">
<option value="">{{ t('aiconfig.none') }}</option>
<option v-for="name in profileNames" :key="name" :value="name">{{ name }}</option>
</select>
</label>
<label class="field">
<span>{{ t('aiconfig.timeout') }}</span>
<input v-model.number="form.toolRouter.timeout" class="input" type="number" min="1" />
</label>
<label class="field">
<span>{{ t('aiconfig.max_tokens') }}</span>
<input v-model.number="form.toolRouter.maxTokens" class="input" type="number" min="1" />
</label>
</div>
</section>
<section class="card space-y-4">
<div class="flex items-center justify-between">
<h2 class="section-title">{{ t('aiconfig.tools') }}</h2>
<button class="btn-secondary" @click="addTool">
<IconPlus :size="16" />
{{ t('aiconfig.add_tool') }}
</button>
</div>
<div v-for="(tool, index) in form.toolRouter.tools" :key="index" class="grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-dk-muted md:grid-cols-12">
<label class="field md:col-span-3">
<span>{{ t('aiconfig.name') }}</span>
<input v-model="tool.name" class="input" />
</label>
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text md:col-span-2 md:pt-6">
<input v-model="tool.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
{{ t('aiconfig.enabled') }}
</label>
<label class="field md:col-span-6">
<span>{{ t('aiconfig.description') }}</span>
<input v-model="tool.description" class="input" />
</label>
<div class="flex items-end md:col-span-1">
<button class="text-sm text-red-600 hover:text-red-700" @click="removeTool(index)">
<IconTrash :size="18" />
</button>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.card {
border-radius: 0.75rem;
border: 1px solid rgb(229 231 235);
background: white;
padding: 1.25rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17 24 39);
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(55 65 81);
}
.input {
border-radius: 0.375rem;
border: 1px solid rgb(209 213 219);
background: white;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: rgb(17 24 39);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus {
border-color: rgb(59 130 246);
box-shadow: 0 0 0 2px rgb(59 130 246 / 0.2);
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.15s ease, color 0.15s ease;
}
.btn-primary {
background: rgb(37 99 235);
color: white;
}
.btn-primary:hover {
background: rgb(29 78 216);
}
.btn-secondary {
border: 1px solid rgb(229 231 235);
color: rgb(75 85 99);
}
.btn-secondary:hover {
background: rgb(243 244 246);
}
.btn-primary:disabled,
.btn-secondary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
:global(.dark) .card {
border-color: var(--color-dk-muted);
background: var(--color-dk-card);
}
:global(.dark) .section-title,
:global(.dark) .field {
color: var(--color-dk-text);
}
:global(.dark) .input {
border-color: var(--color-dk-muted);
background: var(--color-dk-base);
color: var(--color-dk-text);
}
:global(.dark) .btn-secondary {
border-color: var(--color-dk-muted);
color: var(--color-dk-subtle);
}
:global(.dark) .btn-secondary:hover {
background: var(--color-dk-card);
}
</style>
@@ -0,0 +1,228 @@
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconRobot, IconSend, IconTrash, IconUser, IconLoader2 } from '@tabler/icons-vue'
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
import { usePageTitle } from '@/composables/usePageTitle'
import { useToastStore } from '@/stores/toast'
const { t } = useI18n()
const toast = useToastStore()
usePageTitle('appname.aichat')
const messages = ref([])
const inputText = ref('')
const pending = ref(false)
const traces = ref([])
const stats = ref(null)
const profiles = ref([])
const activeProfile = ref('')
const toolRouter = ref(null)
const messageListRef = ref(null)
onMounted(loadProfiles)
async function loadProfiles() {
try {
const res = await fetchOpenAIProfiles()
if (res.errCode === 0 && res.data) {
profiles.value = res.data.profiles || []
activeProfile.value = res.data.active || profiles.value[0]?.name || ''
toolRouter.value = res.data.toolRouter || null
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(message)
}
}
function scrollToBottom() {
nextTick(() => {
const el = messageListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
})
}
function onKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}
function clearChat() {
if (pending.value) return
messages.value = []
traces.value = []
stats.value = null
}
async function sendMessage() {
const text = inputText.value.trim()
if (!text || pending.value) return
inputText.value = ''
traces.value = []
stats.value = null
messages.value.push({ role: 'user', content: text })
const assistantMessage = { role: 'assistant', content: '' }
messages.value.push(assistantMessage)
pending.value = true
scrollToBottom()
const history = messages.value
.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({ role: message.role, content: message.content }))
.slice(0, -1)
try {
await streamChat(history, { openaiName: activeProfile.value }, {
onDelta(delta) {
assistantMessage.content += delta
scrollToBottom()
},
onTrace(frame) {
traces.value.push(frame)
scrollToBottom()
},
onStats(value) {
stats.value = value
},
onError(message) {
if (!assistantMessage.content) {
assistantMessage.content = t('aichat.error_prefix') + message
}
toast.error(message)
scrollToBottom()
},
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
assistantMessage.content = t('aichat.error_prefix') + message
toast.error(message)
} finally {
pending.value = false
scrollToBottom()
}
}
</script>
<template>
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-5xl flex-col px-4 py-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
{{ t('aichat.title') }}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
{{ t('aichat.subtitle') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
:disabled="pending || messages.length === 0"
@click="clearChat"
>
<IconTrash :size="16" />
{{ t('aichat.clear') }}
</button>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
<div class="max-w-md">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
<IconRobot :size="30" />
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
{{ t('aichat.empty_title') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
{{ t('aichat.empty_hint') }}
</p>
</div>
</div>
<div v-else class="space-y-5">
<div
v-for="(message, index) in messages"
:key="index"
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
>
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
<IconRobot :size="18" />
</div>
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
<div v-if="message.role !== 'user' && index === messages.length - 1 && traces.length" class="mb-3 space-y-2">
<div
v-for="(trace, traceIndex) in traces"
:key="traceIndex"
class="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200"
>
<div class="font-medium">
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
</div>
<div v-if="trace.message" class="mt-1 opacity-90">
{{ trace.message }}
</div>
</div>
</div>
<p class="whitespace-pre-wrap break-words">
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
</p>
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
<IconLoader2 :size="14" class="animate-spin" />
{{ t('aichat.streaming') }}
</div>
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
{{ t('aichat.tokens') }}: {{ stats.total_tokens || 0 }}
</div>
</div>
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
<IconUser :size="18" />
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
<div class="flex items-end gap-3">
<textarea
v-model="inputText"
rows="2"
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
:placeholder="t('aichat.input_placeholder')"
:disabled="pending"
@keydown="onKeydown"
/>
<button
type="button"
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="pending || !inputText.trim()"
@click="sendMessage"
>
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
<IconSend v-else :size="18" />
{{ t('aichat.send') }}
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
{{ t('aichat.enter_hint') }}
</p>
</div>
</div>
</div>
</template>
@@ -32,6 +32,7 @@ const tabs = [
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
{ id: 'customer', label: t('customer.title'), to: '/customer' },
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
{ id: 'aiconfig', label: t('aiconfig.title'), to: '/admin/aiconfig' },
]
async function fetchSysAdmins() {