Browse Source

✨ feat: 新增ai助手

Pchen. 16 hours ago
parent
commit
85d4627d52

+ 24 - 0
apis/AIChat/Admin/Detail.js

@@ -0,0 +1,24 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class AdminAIChatDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/AIChat/Conversation/Detail')
+        this.setMethod('GET')
+        this.setPermissionCode('page.admin.aiChat')
+    }
+
+    async onRequest(req, res) {
+        const { id } = req.query
+        if ([id].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        const data = await ConversationService.getAdminConversationDetail({ conversationId: id })
+        if (!data) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在' })
+        return res.json({ ...BaseStdResponse.OK, data })
+    }
+}
+
+module.exports.AdminAIChatDetail = AdminAIChatDetail

+ 29 - 0
apis/AIChat/Admin/List.js

@@ -0,0 +1,29 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class AdminAIChatList extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/AIChat/Conversation/List')
+        this.setMethod('GET')
+        this.setPermissionCode('page.admin.aiChat')
+    }
+
+    async onRequest(req, res) {
+        const { id, conversation_no, create_user, username, state = -1, queryTime, current = 1, pagesize = 20 } = req.query
+        const result = await ConversationService.listAdminConversations({
+            id,
+            conversation_no,
+            create_user,
+            username,
+            state,
+            queryTime,
+            current,
+            pagesize
+        })
+        return res.json({ ...BaseStdResponse.OK, ...result })
+    }
+}
+
+module.exports.AdminAIChatList = AdminAIChatList

+ 40 - 0
apis/AIChat/Bot/Reply.js

@@ -0,0 +1,40 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const OneBotV11 = require('../../../plugin/OneBot/OneBotV11')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class BotReply extends API {
+    constructor() {
+        super()
+        this.noEncrypt()
+        this.setPath('/AIChat/Bot/Reply')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const onebot = OneBotV11.getAiChatOneBotConfig()
+        if (!onebot.enabled) return res.json({ ...BaseStdResponse.ERR, msg: 'AIChat OneBot v11 未启用' })
+
+        const token = req.headers['x-onebot-access-token'] || req.headers['authorization'] || ''
+        if (onebot.callbackToken && !String(token).includes(onebot.callbackToken)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED, msg: 'AIChat 回调 token 校验失败' })
+        }
+
+        const { conversation_id, conversation_no, id, content, images = [] } = req.body || {}
+        const conversationId = conversation_id || id
+        if ([conversationId || conversation_no, content].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '缺少 conversation_id/conversation_no 或 content' })
+        }
+
+        const ok = await ConversationService.addAssistantMessage({
+            conversationId,
+            conversationNo: conversation_no,
+            content,
+            images
+        })
+        if (!ok) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或已删除' })
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports.BotReply = BotReply

+ 27 - 0
apis/AIChat/Conversation/Delete.js

@@ -0,0 +1,27 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class DeleteConversation extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Conversation')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.body
+        if ([uuid, session, id].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+        const ok = await ConversationService.deleteConversation({ uuid, conversationId: id })
+        if (!ok) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或无权删除' })
+        return res.json({ ...BaseStdResponse.OK })
+    }
+}
+
+module.exports.DeleteConversation = DeleteConversation

+ 26 - 0
apis/AIChat/Conversation/List.js

@@ -0,0 +1,26 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class ListConversation extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Conversation/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, current = 1, pagesize = 20 } = req.query
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+        const result = await ConversationService.listUserConversations({ uuid, current, pagesize })
+        return res.json({ ...BaseStdResponse.OK, ...result })
+    }
+}
+
+module.exports.ListConversation = ListConversation

+ 27 - 0
apis/AIChat/Conversation/Save.js

@@ -0,0 +1,27 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class SaveConversation extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Conversation')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, title } = req.body
+        if ([uuid, session].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+        const data = await ConversationService.createConversation({ uuid, title })
+        if (!data?.id) return res.json({ ...BaseStdResponse.DATABASE_ERR })
+        return res.json({ ...BaseStdResponse.OK, data })
+    }
+}
+
+module.exports.SaveConversation = SaveConversation

+ 33 - 0
apis/AIChat/Message/List.js

@@ -0,0 +1,33 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class ListMessage extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Message/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, conversation_id, after_id = 0, current = 1, pagesize = 50 } = req.query
+        if ([uuid, session, conversation_id].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+        const result = await ConversationService.listMessages({
+            uuid,
+            conversationId: conversation_id,
+            afterId: after_id,
+            current,
+            pagesize
+        })
+        if (!result) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或无权查看' })
+        return res.json({ ...BaseStdResponse.OK, ...result })
+    }
+}
+
+module.exports.ListMessage = ListMessage

+ 54 - 0
apis/AIChat/Message/Send.js

@@ -0,0 +1,54 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const AccessControl = require('../../../lib/AccessControl')
+const OneBotV11 = require('../../../plugin/OneBot/OneBotV11')
+const { ConversationService } = require('../../../lib/AIChat/ConversationService')
+
+class SendMessage extends API {
+    constructor() {
+        super()
+        this.setPath('/AIChat/Message')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, conversation_id, content, images = [] } = req.body
+        if ([uuid, session, conversation_id].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        const saved = await ConversationService.addUserMessage({
+            uuid,
+            conversationId: conversation_id,
+            content,
+            images
+        })
+        if (!saved) return res.json({ ...BaseStdResponse.ERR, msg: '会话不存在或无权发送' })
+        if (saved.missingContent) return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '请输入消息或上传图片' })
+
+        res.json({ ...BaseStdResponse.OK, data: { message_id: saved.messageId } })
+
+        try {
+            await OneBotV11.sendAiChatMessage({
+                conversationId: saved.conversationId,
+                conversationNo: saved.conversationNo,
+                senderUuid: uuid,
+                content: saved.content,
+                images: saved.images
+            })
+        } catch (err) {
+            this.logger.error(`AIChat OneBot 转发失败: ${err.stack || err}`)
+            await ConversationService.addSystemMessage({
+                conversationId: saved.conversationId,
+                content: '消息已保存,但暂时无法连接小妍助理,请稍后再试。',
+                status: 'error',
+                errorMsg: err.message || 'OneBot send failed'
+            })
+        }
+    }
+}
+
+module.exports.SendMessage = SendMessage

+ 16 - 0
apis/MCP/aiAssistantMcp/GetMcp.js

@@ -0,0 +1,16 @@
+const API = require('../../../lib/API')
+
+class GetMcp extends API {
+    constructor() {
+        super()
+        this.noEncrypt()
+        this.setPath('/aiAssistantMcp')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        return res.json({ status: 'ok' })
+    }
+}
+
+module.exports.GetMcp = GetMcp

+ 217 - 0
apis/MCP/aiAssistantMcp/McpRPC.js

@@ -0,0 +1,217 @@
+const API = require('../../../lib/API')
+const MCP = require('../../../lib/AIChat/AiAssistantMcp').AI_ASSISTANT_MCP
+
+const sender = {
+    type: 'string',
+    description: 'RunForge user uuid. Required. Only data owned by this user may be read or changed.'
+}
+
+const pageProps = {
+    current: { type: 'integer', minimum: 1, description: 'Page number, default 1' },
+    pagesize: { type: 'integer', minimum: 1, maximum: 20, description: 'Page size, default 10, max 20' }
+}
+
+const tools = [
+    {
+        name: 'list_lepao_accounts',
+        description: 'Query the user owned Lepao account list with optional filters. Never returns internal auto-increment ids.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                student_num: { type: 'string' },
+                name: { type: 'string' },
+                area: { type: 'string' },
+                state: { type: 'integer', description: '-1 all, 0 needs login, 1 normal, 2 abnormal' },
+                auto_run: { type: 'integer', enum: [0, 1] },
+                ...pageProps
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'list_lepao_records',
+        description: 'Query Lepao run records owned by the user with account, name, result and time filters.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                lepao_account: { type: 'string' },
+                name: { type: 'string' },
+                result: { type: 'string' },
+                start_time: { type: 'integer', description: 'Start timestamp in ms' },
+                end_time: { type: 'integer', description: 'End timestamp in ms' },
+                ...pageProps
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'list_lepao_count_ledger',
+        description: 'Query Lepao count change ledger for the user. Never returns internal auto-increment ids.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                biz_type: { type: 'string' },
+                remark: { type: 'string' },
+                start_time: { type: 'integer', description: 'Start timestamp in ms' },
+                end_time: { type: 'integer', description: 'End timestamp in ms' },
+                ...pageProps
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'list_goods',
+        description: 'Query active goods list with optional keyword and category filters. Never returns internal auto-increment ids, icon paths, or other unnecessary media fields.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                keyword: { type: 'string' },
+                category: { type: 'string' },
+                ...pageProps
+            }
+        }
+    },
+    {
+        name: 'list_my_orders',
+        description: 'Query the user order list and order status. Uses order_no instead of internal ids.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                state: { type: 'integer', description: '-1 all, 0 pending payment, 1 paid, 2 cancelled, 3 abnormal, 4 refunded' },
+                order_id: { type: 'string', description: 'Public order number' },
+                ...pageProps
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'save_lepao_account',
+        description: 'Add or partially update a Lepao account owned by the user. student_num identifies the account; provide only fields that should change. Example: to change run area, provide sender, student_num, area only.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                student_num: { type: 'string' },
+                email: { type: 'string' },
+                area: { type: 'string', description: 'Supported examples: 兰花湖校区跑区, 主校区北跑区, 主校区南跑区, 重庆工商大学茶园校区, 随机分配.' },
+                auto_time: { type: 'integer', minimum: -1, maximum: 23 },
+                auto_run: { type: 'integer', enum: [0, 1] },
+                target_count: { type: 'number' },
+                auto_day: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 } },
+                notice_type: { type: 'string', enum: ['email', 'none', 'bot'] },
+                notes: { type: 'string' }
+            },
+            required: ['sender', 'student_num']
+        }
+    },
+    {
+        name: 'save_power_task',
+        description: 'Add or partially update a power query task owned by the user. Existing tasks can be identified by task_id or by area + building + room; provide only fields that should change.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                task_id: { type: 'integer', description: 'Existing task id for compatibility. Prefer area + building + room when possible.' },
+                area: { type: 'string' },
+                building: { type: 'string' },
+                room: { type: 'string' },
+                email: { type: 'string' },
+                lowest: { type: 'number' },
+                notes: { type: 'string' }
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'list_power_bills',
+        description: 'Query power tasks or one task bill/change records owned by the user. Results never expose internal task or record ids.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                task_id: { type: 'integer', description: 'Compatibility id filter when caller already has it.' },
+                area: { type: 'string' },
+                building: { type: 'string' },
+                room: { type: 'string' },
+                ...pageProps
+            },
+            required: ['sender']
+        }
+    },
+    {
+        name: 'query_qxs_book_list',
+        description: 'Query Quxuanshu textbook list with user provided Quxuanshu credentials.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                username: { type: 'string' },
+                password: { type: 'string' }
+            },
+            required: ['sender', 'username', 'password']
+        }
+    },
+    {
+        name: 'submit_work_order',
+        description: 'Submit a support ticket for the current user. Returns no internal auto-increment id; the user can view progress in the ticket list.',
+        inputSchema: {
+            type: 'object',
+            properties: {
+                sender,
+                title: { type: 'string', maxLength: 80 },
+                content: { type: 'string', maxLength: 4000 },
+                email: { type: 'string' },
+                files: { type: 'array', items: { type: 'string' }, maxItems: 6 }
+            },
+            required: ['sender', 'title', 'content']
+        }
+    }
+]
+
+class McpRpc extends API {
+    constructor() {
+        super()
+        this.noEncrypt()
+        this.setPath('/aiAssistantMcp')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { method, params = {}, id: reqId } = req.body || {}
+        try {
+            let result
+            if (method === 'initialize') {
+                result = {
+                    protocolVersion: '2024-11-05',
+                    capabilities: { tools: {} },
+                    serverInfo: { name: 'runforgeAiAssistant', version: '1.0' }
+                }
+            } else if (method === 'tools/list') {
+                result = { tools }
+            } else if (method === 'tools/call') {
+                const { name, arguments: args = {} } = params
+                if (!MCP[name] || typeof MCP[name] !== 'function') {
+                    result = { content: [{ type: 'text', text: '未知工具' }] }
+                } else {
+                    const output = await MCP[name](args)
+                    result = { content: [{ type: 'text', text: output }] }
+                }
+            } else {
+                result = { error: `未知方法: ${method}` }
+            }
+            return res.json({ jsonrpc: '2.0', id: reqId ?? null, result })
+        } catch (err) {
+            return res.json({
+                jsonrpc: '2.0',
+                id: reqId ?? null,
+                error: { code: -32000, message: err.message }
+            })
+        }
+    }
+}
+
+module.exports.McpRpc = McpRpc

+ 14 - 0
config.example.json

@@ -51,5 +51,19 @@
         "selfId": "your-bot-id",
         "botName": "BotName",
         "botUuid": "your-bot-uuid"
+    },
+    "aiChatOnebotv11": {
+        "enabled": false,
+        "transport": "reverse_ws",
+        "reverseWsUrl": "ws://127.0.0.1:15701",
+        "reverseWsPath": "/ws",
+        "reverseWsHost": "0.0.0.0",
+        "reverseWsPort": 15701,
+        "reverseWsToken": "YOUR_AI_CHAT_WS_TOKEN",
+        "callbackToken": "YOUR_AI_CHAT_CALLBACK_TOKEN",
+        "chatSenderNickname": "Xiaoyan Assistant Web User",
+        "selfId": "your-chat-bot-id",
+        "botName": "Xiaoyan Assistant",
+        "botUuid": "your-chat-bot-uuid"
     }
 }

+ 532 - 0
lib/AIChat/AiAssistantMcp.js

@@ -0,0 +1,532 @@
+const db = require('../../plugin/DataBase/db')
+const { axiosWithQgOutbound } = require('../Lepao/qgOutboundAxios')
+const Logger = require('../Logger')
+const path = require('path')
+const QxsBookList = require('../../apis/QuXuanShu/GetBookList').GetBookList
+const EmailTemplate = require('../../plugin/Email/emailTemplate')
+
+const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
+
+function required(values) {
+    return values.some(v => v === '' || v === null || v === undefined)
+}
+
+function isProvided(value) {
+    return value !== '' && value !== null && value !== undefined
+}
+
+function pageArgs({ current = 1, pagesize = 10 } = {}, max = 20) {
+    const c = Math.max(1, Number.parseInt(current, 10) || 1)
+    const p = Math.min(Math.max(1, Number.parseInt(pagesize, 10) || 10), max)
+    return { current: c, pagesize: p, offset: (c - 1) * p }
+}
+
+function asJson(data) {
+    return JSON.stringify(data)
+}
+
+function parseJson(value, fallback = []) {
+    if (!value) return fallback
+    if (Array.isArray(value)) return value
+    try {
+        const parsed = JSON.parse(value)
+        return parsed == null ? fallback : parsed
+    } catch (_) {
+        return fallback
+    }
+}
+
+function stateText(value, map) {
+    return map[value] || String(value)
+}
+
+function normalizeText(value, max = 2000) {
+    return String(value || '').trim().slice(0, max)
+}
+
+function normalizeRunArea(area) {
+    if (!isProvided(area)) return ''
+    const value = String(area).trim()
+    if (value === '随机分配') return ''
+    const allowed = new Set([
+        '兰花湖校区跑区',
+        '主校区北跑区',
+        '主校区南跑区',
+        '重庆工商大学茶园校区',
+        ''
+    ])
+    return allowed.has(value) ? value : null
+}
+
+function mapLepaoAccount(row) {
+    return {
+        student_num: row.student_num,
+        name: row.name || '',
+        state: stateText(row.state, { 0: '需登录', 1: '正常', 2: '异常' }),
+        area: row.area || '随机分配',
+        auto_run: row.auto_run === 1,
+        auto_time: row.auto_time,
+        today_auto_time: row.today_auto_time,
+        target_count: row.target_count,
+        total_num: row.total_num,
+        term_num: row.term_num,
+        auto_day: parseJson(row.auto_day, []),
+        notice_type: row.notice_type,
+        email: row.email || '',
+        notes: row.notes || ''
+    }
+}
+
+class AiAssistantMcp {
+    constructor() {
+        this.logger = new Logger(path.join(__dirname, '../../logs/AiAssistantMcp.log'), 'INFO')
+        this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+        this.banEmailList = ['icloud.com']
+    }
+
+    async list_lepao_accounts(args) {
+        const { sender, student_num, name, area, state, auto_run, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['create_user = ?']
+        const params = [sender]
+        if (student_num) {
+            where.push('student_num LIKE ?')
+            params.push(`%${student_num}%`)
+        }
+        if (name) {
+            where.push('name LIKE ?')
+            params.push(`%${name}%`)
+        }
+        if (area !== undefined && area !== null && area !== '') {
+            const normalizedArea = normalizeRunArea(area)
+            if (normalizedArea === null) return '跑区不合法'
+            where.push('area = ?')
+            params.push(normalizedArea)
+        }
+        if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
+            where.push('state = ?')
+            params.push(state)
+        }
+        if (auto_run !== undefined && auto_run !== null && auto_run !== '') {
+            where.push('auto_run = ?')
+            params.push(Number(auto_run))
+        }
+        const rows = await db.query(
+            `SELECT student_num, name, state, area, auto_run, auto_time, today_auto_time,
+                    target_count, total_num, term_num, auto_day, notice_type, email, notes
+             FROM lepao_account
+             WHERE ${where.join(' AND ')}
+             ORDER BY update_time DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        if (!rows) return '查询失败'
+        return asJson({ list: rows.map(mapLepaoAccount) })
+    }
+
+    async list_lepao_records(args) {
+        const { sender, lepao_account, name, start_time, end_time, result, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['(r.uuid = ? OR a.create_user = ?)']
+        const params = [sender, sender]
+        if (lepao_account) {
+            where.push('r.lepao_account LIKE ?')
+            params.push(`%${lepao_account}%`)
+        }
+        if (name) {
+            where.push('a.name LIKE ?')
+            params.push(`%${name}%`)
+        }
+        if (result) {
+            where.push('r.result LIKE ?')
+            params.push(`%${result}%`)
+        }
+        if (start_time) {
+            where.push('r.time >= ?')
+            params.push(start_time)
+        }
+        if (end_time) {
+            where.push('r.time < ?')
+            params.push(end_time)
+        }
+        const rows = await db.query(
+            `SELECT r.public_id, r.time, r.result, r.run_mode, r.lepao_account, a.name
+             FROM lepao_record r
+             LEFT JOIN lepao_account a ON r.lepao_account = a.student_num
+             WHERE ${where.join(' AND ')}
+             ORDER BY r.id DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        if (!rows) return '查询失败'
+        return asJson({ list: rows })
+    }
+
+    async list_lepao_count_ledger(args) {
+        const { sender, biz_type, remark, start_time, end_time, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['user_uuid = ?']
+        const params = [sender]
+        if (biz_type) {
+            where.push('biz_type = ?')
+            params.push(biz_type)
+        }
+        if (remark) {
+            where.push('remark LIKE ?')
+            params.push(`%${remark}%`)
+        }
+        if (start_time) {
+            where.push('created_at >= FROM_UNIXTIME(? / 1000)')
+            params.push(start_time)
+        }
+        if (end_time) {
+            where.push('created_at <= FROM_UNIXTIME(? / 1000)')
+            params.push(end_time)
+        }
+        const rows = await db.query(
+            `SELECT delta, balance_before, balance_after, biz_type, remark,
+                    UNIX_TIMESTAMP(created_at) * 1000 AS created_at
+             FROM lepao_count_ledger
+             WHERE ${where.join(' AND ')}
+             ORDER BY id DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        if (!rows) return '查询失败'
+        return asJson({ list: rows })
+    }
+
+    async list_goods(args) {
+        const { keyword, category, current, pagesize } = args
+        const page = pageArgs({ current, pagesize })
+        const where = ['state = 1']
+        const params = []
+        if (keyword) {
+            where.push('name LIKE ?')
+            params.push(`%${keyword}%`)
+        }
+        if (category) {
+            where.push('category = ?')
+            params.push(category)
+        }
+        const rows = await db.query(
+            `SELECT name, price, num, \`limit\`, description, category, features
+             FROM goods
+             WHERE ${where.join(' AND ')}
+             ORDER BY id DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        if (!rows) return '查询失败'
+        return asJson({ list: rows.map(r => ({ ...r, features: parseJson(r.features, []) })) })
+    }
+
+    async list_my_orders(args) {
+        const { sender, state, order_id, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const where = ['o.create_user = ?']
+        const params = [sender]
+        if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
+            where.push('o.state = ?')
+            params.push(state)
+        }
+        if (order_id) {
+            where.push('o.orderId = ?')
+            params.push(order_id)
+        }
+        const rows = await db.query(
+            `SELECT o.orderId, o.state, o.create_time, o.price, o.pay_type, g.name AS goods_name
+             FROM orders o
+             JOIN goods g ON o.goods_id = g.id
+             WHERE ${where.join(' AND ')}
+             ORDER BY o.id DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        if (!rows) return '查询失败'
+        return asJson({
+            list: rows.map(r => ({
+                order_no: r.orderId,
+                state: stateText(r.state, { 0: '待支付', 1: '已支付', 2: '已取消', 3: '异常', 4: '已退款' }),
+                create_time: r.create_time,
+                price: r.price,
+                pay_type: r.pay_type,
+                goods_name: r.goods_name
+            }))
+        })
+    }
+
+    async save_lepao_account(args) {
+        const { sender, student_num } = args
+        if (required([sender, student_num])) return '缺少 sender 或 student_num'
+
+        const existRows = await db.query(
+            `SELECT id, create_user, student_num, email, area, auto_time, auto_run, target_count, auto_day, notice_type, notes
+             FROM lepao_account
+             WHERE student_num = ?
+             LIMIT 1`,
+            [student_num]
+        )
+        if (!existRows) return '查询账号失败'
+        if (existRows.length > 0 && existRows[0].create_user && existRows[0].create_user !== sender) {
+            return '该乐跑账号已被其他用户绑定'
+        }
+
+        const fields = []
+        const params = []
+        const mutable = [
+            ['email', 'email'],
+            ['auto_time', 'auto_time'],
+            ['auto_run', 'auto_run'],
+            ['target_count', 'target_count'],
+            ['notice_type', 'notice_type'],
+            ['notes', 'notes']
+        ]
+
+        if (isProvided(args.area)) {
+            const area = normalizeRunArea(args.area)
+            if (area === null) return '跑区不合法'
+            fields.push('area = ?')
+            params.push(area)
+        }
+
+        if (isProvided(args.email) && !this.emailRegex.test(args.email)) return '邮箱格式不正确'
+        if (isProvided(args.notice_type) && !['email', 'none', 'bot'].includes(args.notice_type)) return '通知方式不合法'
+        if (isProvided(args.auto_time)) {
+            const autoTime = Number(args.auto_time)
+            if (!Number.isInteger(autoTime) || autoTime < -1 || autoTime > 23) return '自动乐跑时间不合法'
+            args.auto_time = autoTime
+        }
+        if (isProvided(args.auto_run) && ![0, 1].includes(Number(args.auto_run))) return 'auto_run 只能为 0 或 1'
+        if (isProvided(args.auto_day)) {
+            if (!Array.isArray(args.auto_day) || !args.auto_day.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
+            fields.push('auto_day = ?')
+            params.push(JSON.stringify(args.auto_day))
+        }
+        for (const [argName, column] of mutable) {
+            if (isProvided(args[argName])) {
+                fields.push(`${column} = ?`)
+                params.push(args[argName])
+            }
+        }
+
+        const now = Date.now()
+        let result
+        if (existRows.length > 0) {
+            if (fields.length === 0) return '没有需要修改的字段'
+            fields.push('update_time = ?', 'create_user = ?')
+            params.push(now, sender)
+            result = await db.query(
+                `UPDATE lepao_account SET ${fields.join(', ')} WHERE student_num = ? AND (create_user = ? OR create_user IS NULL)`,
+                [...params, student_num, sender]
+            )
+        } else {
+            const area = isProvided(args.area) ? normalizeRunArea(args.area) : ''
+            if (area === null) return '跑区不合法'
+            const autoDay = isProvided(args.auto_day) ? args.auto_day : [0, 1, 2, 3, 4, 5, 6]
+            if (!Array.isArray(autoDay) || !autoDay.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) return '自动乐跑星期不合法'
+            const email = args.email || ''
+            if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
+            result = await db.query(
+                `INSERT INTO lepao_account
+                 (student_num, email, area, auto_time, auto_run, target_count, create_user, create_time, update_time, notes, auto_day, notice_type)
+                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+                [
+                    student_num,
+                    email,
+                    area,
+                    isProvided(args.auto_time) ? Number(args.auto_time) : -1,
+                    isProvided(args.auto_run) ? Number(args.auto_run) : 1,
+                    isProvided(args.target_count) ? args.target_count : 30,
+                    sender,
+                    now,
+                    now,
+                    args.notes || '',
+                    JSON.stringify(autoDay),
+                    args.notice_type || 'email'
+                ]
+            )
+            const bindCode = Math.random().toString(36).slice(2, 8).toUpperCase()
+            await db.query('INSERT IGNORE INTO lepao_extra (student_num, bind_code) VALUES (?, ?)', [student_num, bindCode])
+        }
+        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
+        return asJson({ success: true, student_num })
+    }
+
+    async save_power_task(args) {
+        const { sender, task_id, area, building, room } = args
+        if (required([sender])) return '缺少 sender'
+
+        const now = Date.now()
+        let existing = []
+        if (task_id) {
+            existing = await db.query('SELECT id, area, building, room FROM power_task WHERE id = ? AND create_user = ? LIMIT 1', [task_id, sender])
+        } else if ([area, building, room].every(isProvided)) {
+            existing = await db.query(
+                'SELECT id, area, building, room FROM power_task WHERE create_user = ? AND area = ? AND building = ? AND room = ? LIMIT 1',
+                [sender, area, building, room]
+            )
+        }
+        if (!existing) return '查询电费任务失败'
+
+        const fields = []
+        const params = []
+        for (const key of ['area', 'building', 'room', 'email', 'lowest', 'notes']) {
+            if (isProvided(args[key])) {
+                fields.push(`${key} = ?`)
+                params.push(args[key])
+            }
+        }
+
+        let result
+        if (existing.length > 0) {
+            if (fields.length === 0) return '没有需要修改的字段'
+            fields.push('is_notice = 0', 'update_time = ?')
+            params.push(now)
+            result = await db.query(
+                `UPDATE power_task SET ${fields.join(', ')} WHERE id = ? AND create_user = ?`,
+                [...params, existing[0].id, sender]
+            )
+        } else {
+            if (required([area, building, room, args.lowest])) return '新增电费任务需提供 area、building、room、lowest'
+            let balance = ''
+            let koufeiDate = ''
+            try {
+                const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
+                const apiRes = await axiosWithQgOutbound({ method: 'get', url: endpoint, timeout: 15000, logger: this.logger, scene: 'AiAssistantPowerAccount' })
+                balance = apiRes?.data?.[0]?.[1] ?? ''
+                koufeiDate = apiRes?.data?.[0]?.[2] ?? ''
+            } catch (_) { }
+            result = await db.query(
+                'INSERT INTO power_task (create_user, create_time, update_time, area, building, room, email, lowest, notes, balance, koufei_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+                [sender, now, now, area, building, room, args.email || '', args.lowest, args.notes || '', balance, koufeiDate]
+            )
+        }
+        if (!result || result.affectedRows < 1) return '保存失败或无权修改'
+        return asJson({ success: true, area, building, room })
+    }
+
+    async list_power_bills(args) {
+        const { sender, task_id, area, building, room, current, pagesize } = args
+        if (required([sender])) return '缺少 sender'
+        const page = pageArgs({ current, pagesize })
+        const taskWhere = ['create_user = ?']
+        const taskParams = [sender]
+        if (task_id) {
+            taskWhere.push('id = ?')
+            taskParams.push(task_id)
+        }
+        if (area) {
+            taskWhere.push('area = ?')
+            taskParams.push(area)
+        }
+        if (building) {
+            taskWhere.push('building = ?')
+            taskParams.push(building)
+        }
+        if (room) {
+            taskWhere.push('room = ?')
+            taskParams.push(room)
+        }
+        const tasks = await db.query(
+            `SELECT id, area, building, room, balance, lowest, koufei_date, update_time, notes
+             FROM power_task
+             WHERE ${taskWhere.join(' AND ')}
+             ORDER BY id DESC
+             LIMIT ? OFFSET ?`,
+            [...taskParams, String(page.pagesize), String(page.offset)]
+        )
+        if (!tasks) return '查询失败'
+        if (!task_id && !(area && building && room)) {
+            return asJson({ tasks: tasks.map(({ id, ...task }) => task) })
+        }
+        if (tasks.length === 0) return asJson({ task: null, records: [] })
+
+        const records = await db.query(
+            `SELECT time, balance, old_balance, change_time
+             FROM power_change_record
+             WHERE task_id = ?
+             ORDER BY id DESC
+             LIMIT ? OFFSET ?`,
+            [tasks[0].id, String(page.pagesize), String(page.offset)]
+        )
+        const { id, ...task } = tasks[0]
+        return asJson({ task, records: records || [] })
+    }
+
+    async query_qxs_book_list(args) {
+        const { sender, username, password } = args
+        if (required([sender, username, password])) return '缺少 sender、username 或 password'
+        try {
+            const qxs = new QxsBookList()
+            const { accessToken } = await qxs.qxsLogin(username, password)
+            const { userInfo } = await qxs.qsxUserInfo(accessToken)
+            const { bookList } = await qxs.qsxGetList(accessToken, userInfo.termCode)
+            return asJson({
+                user: {
+                    realname: userInfo.userName,
+                    termName: userInfo.termName
+                },
+                list: (bookList || []).map(book => ({
+                    title: book.title || book.bookName || '',
+                    isbn: book.isbn || '',
+                    author: book.author || '',
+                    publisher: book.publisher || '',
+                    className: book.className || ''
+                }))
+            })
+        } catch (err) {
+            return err.message || '查询趣选书书单失败'
+        }
+    }
+
+    async submit_work_order(args) {
+        const { sender, title, content, email = '', files = [] } = args
+        if (required([sender, title, content])) return '缺少 sender、title 或 content'
+        if (email && !this.emailRegex.test(email)) return '邮箱格式不正确'
+        if (email && this.banEmailList.includes(email.split('@')[1].toLowerCase())) return '暂不支持该邮箱域名'
+
+        const safeTitle = normalizeText(title, 80)
+        const safeContent = normalizeText(content, 4000)
+        const safeFiles = Array.isArray(files) ? files.map(item => String(item || '').trim()).filter(Boolean).slice(0, 6) : []
+        if (!safeTitle || !safeContent) return '标题或内容不能为空'
+
+        const time = Date.now()
+        const msg = [
+            {
+                time,
+                content: safeContent,
+                files: safeFiles,
+                uuid: sender,
+                type: 'user'
+            },
+            {
+                time,
+                content: '您的问题已收到,我们将尽快处理,请耐心等待。',
+                uuid: ASSISTANT_UUID,
+                type: 'system'
+            }
+        ]
+        const result = await db.query(
+            'INSERT INTO work_order (title, email, msg, create_user, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?)',
+            [safeTitle, email, msg, sender, time, time]
+        )
+        if (!result || result.affectedRows !== 1) return '提交工单失败'
+
+        const kefuRows = await db.query(`
+            SELECT email FROM users
+            WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
+        `)
+        const emails = [...new Set((kefuRows || []).map(row => row.email).filter(Boolean))]
+        for (const targetEmail of emails) {
+            await EmailTemplate.orderNewReply(targetEmail, { id: result.insertId, content: safeContent, files: safeFiles })
+        }
+        return asJson({ success: true, message: '工单提交成功,可在工单列表查看处理进度' })
+    }
+}
+
+module.exports.AI_ASSISTANT_MCP = new AiAssistantMcp()

+ 357 - 0
lib/AIChat/ConversationService.js

@@ -0,0 +1,357 @@
+const crypto = require('crypto')
+const db = require('../../plugin/DataBase/db')
+
+const ACTIVE_STATE = 1
+const DELETED_STATE = 2
+const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
+
+function toPositiveInt(value, fallback = null) {
+    const n = Number(value)
+    if (!Number.isInteger(n) || n <= 0) return fallback
+    return n
+}
+
+function normalizePage(current = 1, pagesize = 20, maxPageSize = 50) {
+    const c = toPositiveInt(current, 1)
+    const p = Math.min(toPositiveInt(pagesize, 20), maxPageSize)
+    return { current: c, pagesize: p, offset: (c - 1) * p }
+}
+
+function normalizeText(value, max = 2000) {
+    return String(value || '').trim().slice(0, max)
+}
+
+function normalizeImages(images) {
+    if (!Array.isArray(images)) return []
+    return images
+        .map((item) => {
+            if (typeof item === 'string') return item
+            return item?.url || item?.response?.data?.picturePath || item?.response?.picturePath || ''
+        })
+        .map((item) => String(item || '').trim())
+        .filter(Boolean)
+        .slice(0, 6)
+}
+
+function buildPreview(content, images = []) {
+    const text = normalizeText(content, 160).replace(/\s+/g, ' ')
+    if (text) return text
+    return images.length > 0 ? `[图片] x${images.length}` : ''
+}
+
+function parseImages(value) {
+    if (!value) return []
+    if (Array.isArray(value)) return value
+    try {
+        const parsed = JSON.parse(value)
+        return Array.isArray(parsed) ? parsed : []
+    } catch (_) {
+        return []
+    }
+}
+
+function normalizeMessage(row) {
+    return {
+        ...row,
+        images: parseImages(row.images)
+    }
+}
+
+async function generateConversationNo() {
+    for (let i = 0; i < 8; i++) {
+        const conversationNo = String(crypto.randomInt(100000000000, 999999999999))
+        const rows = await db.query('SELECT id FROM ai_chat_conversation WHERE conversation_no = ? LIMIT 1', [conversationNo])
+        if (!rows || rows.length === 0) return conversationNo
+    }
+    return `${String(Date.now()).slice(1)}${crypto.randomInt(100, 999)}`
+}
+
+async function assertUserConversation(conversationId, uuid) {
+    const rows = await db.query(
+        'SELECT id, conversation_no, create_user, title, state FROM ai_chat_conversation WHERE id = ? AND create_user = ? AND state = ? LIMIT 1',
+        [conversationId, uuid, ACTIVE_STATE]
+    )
+    return rows && rows.length === 1 ? rows[0] : null
+}
+
+class ConversationService {
+    async listUserConversations({ uuid, current, pagesize }) {
+        const page = normalizePage(current, pagesize)
+        const rows = await db.query(
+            `SELECT id, conversation_no, title, state, last_message_preview, last_message_time, create_time, update_time
+             FROM ai_chat_conversation
+             WHERE create_user = ? AND state = ?
+             ORDER BY update_time DESC
+             LIMIT ? OFFSET ?`,
+            [uuid, ACTIVE_STATE, String(page.pagesize), String(page.offset)]
+        )
+        const countRows = await db.query(
+            'SELECT COUNT(*) AS total FROM ai_chat_conversation WHERE create_user = ? AND state = ?',
+            [uuid, ACTIVE_STATE]
+        )
+        return {
+            data: rows || [],
+            pagination: {
+                current: page.current,
+                pagesize: page.pagesize,
+                total: Number(countRows?.[0]?.total || 0)
+            }
+        }
+    }
+
+    async createConversation({ uuid, title }) {
+        const emptyRows = await db.query(
+            `SELECT id, conversation_no, title, state, last_message_preview, last_message_time, create_time, update_time
+             FROM ai_chat_conversation
+             WHERE create_user = ? AND state = ? AND last_message_time = 0
+             ORDER BY update_time DESC
+             LIMIT 1`,
+            [uuid, ACTIVE_STATE]
+        )
+        if (emptyRows && emptyRows.length === 1) {
+            return { ...emptyRows[0], reused: true }
+        }
+
+        const now = Date.now()
+        const safeTitle = normalizeText(title, 80) || '小妍助理对话'
+        const conversationNo = await generateConversationNo()
+        const result = await db.query(
+            `INSERT INTO ai_chat_conversation
+             (conversation_no, create_user, title, state, last_message_preview, last_message_time, create_time, update_time)
+             VALUES (?, ?, ?, ?, '', 0, ?, ?)`,
+            [conversationNo, uuid, safeTitle, ACTIVE_STATE, now, now]
+        )
+        return {
+            id: result?.insertId,
+            conversation_no: conversationNo,
+            title: safeTitle,
+            state: ACTIVE_STATE,
+            last_message_preview: '',
+            last_message_time: 0,
+            create_time: now,
+            update_time: now,
+            reused: false
+        }
+    }
+
+    async deleteConversation({ uuid, conversationId }) {
+        const now = Date.now()
+        const result = await db.query(
+            'UPDATE ai_chat_conversation SET state = ?, update_time = ? WHERE id = ? AND create_user = ? AND state = ?',
+            [DELETED_STATE, now, conversationId, uuid, ACTIVE_STATE]
+        )
+        return result?.affectedRows === 1
+    }
+
+    async listMessages({ uuid, conversationId, afterId = 0, current, pagesize }) {
+        const conv = await assertUserConversation(conversationId, uuid)
+        if (!conv) return null
+
+        const after = Number(afterId || 0)
+        if (Number.isFinite(after) && after > 0) {
+            const rows = await db.query(
+                `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+                 FROM ai_chat_message
+                 WHERE conversation_id = ? AND create_user = ? AND id > ?
+                 ORDER BY id ASC
+                 LIMIT 100`,
+                [conversationId, uuid, after]
+            )
+            return { data: (rows || []).map(normalizeMessage), pagination: null }
+        }
+
+        const page = normalizePage(current, pagesize, 100)
+        const rows = await db.query(
+            `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+             FROM ai_chat_message
+             WHERE conversation_id = ? AND create_user = ?
+             ORDER BY id DESC
+             LIMIT ? OFFSET ?`,
+            [conversationId, uuid, String(page.pagesize), String(page.offset)]
+        )
+        const countRows = await db.query(
+            'SELECT COUNT(*) AS total FROM ai_chat_message WHERE conversation_id = ? AND create_user = ?',
+            [conversationId, uuid]
+        )
+        return {
+            data: (rows || []).map(normalizeMessage).reverse(),
+            pagination: {
+                current: page.current,
+                pagesize: page.pagesize,
+                total: Number(countRows?.[0]?.total || 0)
+            }
+        }
+    }
+
+    async addUserMessage({ uuid, conversationId, content, images }) {
+        const conv = await assertUserConversation(conversationId, uuid)
+        if (!conv) return null
+
+        const text = normalizeText(content, 2000)
+        const imgs = normalizeImages(images)
+        if (!text && imgs.length === 0) return { missingContent: true }
+
+        const now = Date.now()
+        const result = await db.query(
+            `INSERT INTO ai_chat_message
+             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'user', ?, ?, 'done', '', ?, ?)`,
+            [conversationId, uuid, text, JSON.stringify(imgs), now, now]
+        )
+        const preview = buildPreview(text, imgs)
+        const title = conv.title === '小妍助理对话' && preview ? preview.slice(0, 40) : conv.title
+        await db.query(
+            'UPDATE ai_chat_conversation SET title = ?, last_message_preview = ?, last_message_time = ?, update_time = ? WHERE id = ?',
+            [title, preview, now, now, conversationId]
+        )
+        return {
+            conversationId,
+            conversationNo: conv.conversation_no,
+            messageId: result?.insertId,
+            content: text,
+            images: imgs
+        }
+    }
+
+    async addAssistantMessage({ conversationId, conversationNo, content, images = [], status = 'done', errorMsg = '' }) {
+        const whereSql = conversationNo ? 'conversation_no = ?' : 'id = ?'
+        const whereValue = conversationNo || conversationId
+        const rows = await db.query(
+            `SELECT id, create_user FROM ai_chat_conversation WHERE ${whereSql} AND state = ? LIMIT 1`,
+            [whereValue, ACTIVE_STATE]
+        )
+        if (!rows || rows.length !== 1) return false
+
+        const targetConversationId = rows[0].id
+        const uuid = rows[0].create_user
+        const text = normalizeText(content, 4000)
+        const imgs = normalizeImages(images)
+        if (!text && imgs.length === 0 && status !== 'error') return false
+
+        const now = Date.now()
+        await db.query(
+            `INSERT INTO ai_chat_message
+             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'assistant', ?, ?, ?, ?, ?, ?)`,
+            [targetConversationId, uuid, text, JSON.stringify(imgs), status, normalizeText(errorMsg, 200), now, now]
+        )
+        await db.query(
+            'UPDATE ai_chat_conversation SET last_message_preview = ?, last_message_time = ?, update_time = ? WHERE id = ?',
+            [buildPreview(text, imgs), now, now, targetConversationId]
+        )
+        return true
+    }
+
+    async addSystemMessage({ conversationId, content, status = 'done', errorMsg = '' }) {
+        const rows = await db.query(
+            'SELECT id, create_user FROM ai_chat_conversation WHERE id = ? AND state = ? LIMIT 1',
+            [conversationId, ACTIVE_STATE]
+        )
+        if (!rows || rows.length !== 1) return false
+        const now = Date.now()
+        await db.query(
+            `INSERT INTO ai_chat_message
+             (conversation_id, create_user, role, content, images, status, error_msg, create_time, update_time)
+             VALUES (?, ?, 'system', ?, JSON_ARRAY(), ?, ?, ?, ?)`,
+            [conversationId, rows[0].create_user, normalizeText(content, 1000), status, normalizeText(errorMsg, 200), now, now]
+        )
+        return true
+    }
+
+    async listAdminConversations({ id, conversation_no, create_user, username, state, queryTime, current, pagesize }) {
+        const page = normalizePage(current, pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+
+        if (id) {
+            where.push('c.id = ?')
+            params.push(id)
+            countParams.push(id)
+        }
+        if (conversation_no) {
+            where.push('c.conversation_no = ?')
+            params.push(conversation_no)
+            countParams.push(conversation_no)
+        }
+        if (create_user) {
+            where.push('c.create_user LIKE ?')
+            params.push(`%${create_user}%`)
+            countParams.push(`%${create_user}%`)
+        }
+        if (username) {
+            where.push('u.username LIKE ?')
+            params.push(`%${username}%`)
+            countParams.push(`%${username}%`)
+        }
+        if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
+            where.push('c.state = ?')
+            params.push(state)
+            countParams.push(state)
+        }
+        if (Array.isArray(queryTime) && queryTime.length === 2) {
+            where.push('c.update_time >= ? AND c.update_time < ?')
+            params.push(queryTime[0], queryTime[1])
+            countParams.push(queryTime[0], queryTime[1])
+        }
+
+        const whereSql = where.join(' AND ')
+        const rows = await db.query(
+            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.state, c.last_message_preview, c.last_message_time,
+                    c.create_time, c.update_time, u.username
+             FROM ai_chat_conversation c
+             LEFT JOIN users u ON u.uuid = c.create_user
+             WHERE ${whereSql}
+             ORDER BY c.update_time DESC
+             LIMIT ? OFFSET ?`,
+            [...params, String(page.pagesize), String(page.offset)]
+        )
+        const countRows = await db.query(
+            `SELECT COUNT(*) AS total
+             FROM ai_chat_conversation c
+             LEFT JOIN users u ON u.uuid = c.create_user
+             WHERE ${whereSql}`,
+            countParams
+        )
+        return {
+            data: rows || [],
+            pagination: {
+                current: page.current,
+                pagesize: page.pagesize,
+                total: Number(countRows?.[0]?.total || 0)
+            }
+        }
+    }
+
+    async getAdminConversationDetail({ conversationId }) {
+        const convRows = await db.query(
+            `SELECT c.id, c.conversation_no, c.create_user, c.title, c.state, c.last_message_preview, c.last_message_time,
+                    c.create_time, c.update_time, u.username
+             FROM ai_chat_conversation c
+             LEFT JOIN users u ON u.uuid = c.create_user
+             WHERE c.id = ?
+             LIMIT 1`,
+            [conversationId]
+        )
+        if (!convRows || convRows.length !== 1) return null
+        const msgRows = await db.query(
+            `SELECT id, conversation_id, role, content, images, status, error_msg, create_time, update_time
+             FROM ai_chat_message
+             WHERE conversation_id = ?
+             ORDER BY id ASC`,
+            [conversationId]
+        )
+        return {
+            ...convRows[0],
+            messages: (msgRows || []).map(normalizeMessage),
+            assistantUuid: ASSISTANT_UUID
+        }
+    }
+}
+
+module.exports = {
+    ConversationService: new ConversationService(),
+    normalizeImages,
+    normalizeText,
+    ASSISTANT_UUID
+}

+ 6 - 0
lib/PermissionCatalog.js

@@ -26,6 +26,7 @@ const DEFAULT_PERMISSION_POINTS = [
     { code: 'page.admin.userList', name: '用户管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.userList', remark: '访问后台用户管理页面' },
     { code: 'page.admin.lepaoAccount', name: '乐跑账号管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.lepaoAccount', remark: '访问后台乐跑账号管理页面' },
     { code: 'page.admin.service.orderList', name: '工单管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.service.orderList', remark: '访问后台工单管理页面' },
+    { code: 'page.admin.aiChat', name: 'AI对话管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.aiChat.list', remark: '访问后台AI助手对话管理页面' },
     { code: 'page.admin.goods.sendCountRequestList', name: '赠送审核', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.goods.sendCountRequestList', remark: '访问乐跑次数赠送审核页面' },
     { code: 'page.service.createOrder', name: '提交工单', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'service.createOrder', remark: '访问用户提交工单页面' },
     { code: 'page.lepao.accountList', name: '乐跑账号', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'lepao.accountList', remark: '访问用户乐跑账号页面' },
@@ -60,6 +61,8 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'page', resource_key: 'admin.userList', required_codes: ['page.admin.userList'] },
     { resource_type: 'page', resource_key: 'admin.lepaoAccount', required_codes: ['page.admin.lepaoAccount'] },
     { resource_type: 'page', resource_key: 'admin.service.orderList', required_codes: ['page.admin.service.orderList'] },
+    { resource_type: 'page', resource_key: 'admin.aiChat.list', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'page', resource_key: 'admin.aiChat.detail', required_codes: ['page.admin.aiChat'] },
     { resource_type: 'page', resource_key: 'admin.goods.sendCountRequestList', required_codes: ['page.admin.goods.sendCountRequestList'] },
     { resource_type: 'page', resource_key: 'service.createOrder', required_codes: ['page.service.createOrder'] },
     { resource_type: 'page', resource_key: 'lepao.accountList', required_codes: ['page.lepao.accountList'] },
@@ -125,6 +128,8 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'api', resource_key: 'GET /Admin/Kefu/Order', api_method: 'GET', api_path: '/Admin/Kefu/Order', required_codes: ['page.admin.service.orderList'] },
     { resource_type: 'api', resource_key: 'GET /Admin/Kefu/OrderDetail', api_method: 'GET', api_path: '/Admin/Kefu/OrderDetail', required_codes: ['page.admin.service.orderList'] },
     { resource_type: 'api', resource_key: 'PUT /Admin/Kefu/Order', api_method: 'PUT', api_path: '/Admin/Kefu/Order', required_codes: ['action.service.replyOrder'] },
+    { resource_type: 'api', resource_key: 'GET /Admin/AIChat/Conversation/List', api_method: 'GET', api_path: '/Admin/AIChat/Conversation/List', required_codes: ['page.admin.aiChat'] },
+    { resource_type: 'api', resource_key: 'GET /Admin/AIChat/Conversation/Detail', api_method: 'GET', api_path: '/Admin/AIChat/Conversation/Detail', required_codes: ['page.admin.aiChat'] },
     { resource_type: 'api', resource_key: 'GET /Admin/Goods/SendCountRequest/List', api_method: 'GET', api_path: '/Admin/Goods/SendCountRequest/List', required_codes: ['page.admin.goods.sendCountRequestList'] },
     { resource_type: 'api', resource_key: 'POST /Admin/Goods/SendCountRequest/Approve', api_method: 'POST', api_path: '/Admin/Goods/SendCountRequest/Approve', required_codes: ['action.goods.reviewSendCount'] },
     { resource_type: 'api', resource_key: 'POST /Admin/Goods/SendCountRequest/Reject', api_method: 'POST', api_path: '/Admin/Goods/SendCountRequest/Reject', required_codes: ['action.goods.reviewSendCount'] }
@@ -148,6 +153,7 @@ const LEGACY_ROLE_PERMISSION_MAP = {
         'page.admin.userList',
         'page.admin.lepaoAccount',
         'page.admin.service.orderList',
+        'page.admin.aiChat',
         'page.admin.goods.sendCountRequestList',
         'action.user.changeCount',
         'action.user.ban',

+ 291 - 139
plugin/OneBot/OneBotV11.js

@@ -4,14 +4,29 @@ const config = require('../../config.json')
 const Logger = require('../../lib/Logger')
 const db = require('../DataBase/db.js')
 const EmailTemplate = require('../Email/emailTemplate')
+const { ConversationService } = require('../../lib/AIChat/ConversationService')
 
 const logger = new Logger(path.join(__dirname, '../../logs/OneBotV11.log'), 'INFO')
-let wsClient = null
-let connectingPromise = null
-let reconnectTimer = null
-let wsApiClient = null
-let connectingApiPromise = null
-let apiReconnectTimer = null
+
+const BOT_KIND_WORKORDER = 'workorder'
+const BOT_KIND_AI_CHAT = 'aiChat'
+
+function createState(kind) {
+    return {
+        kind,
+        wsClient: null,
+        connectingPromise: null,
+        reconnectTimer: null,
+        wsApiClient: null,
+        connectingApiPromise: null,
+        apiReconnectTimer: null
+    }
+}
+
+const botStates = {
+    [BOT_KIND_WORKORDER]: createState(BOT_KIND_WORKORDER),
+    [BOT_KIND_AI_CHAT]: createState(BOT_KIND_AI_CHAT)
+}
 
 function stringifySafe(data) {
     try {
@@ -21,25 +36,53 @@ function stringifySafe(data) {
     }
 }
 
-function getOneBotConfig() {
-    const onebot = config.onebotv11 || {}
+function getRawConfig(kind) {
+    if (kind === BOT_KIND_AI_CHAT) return config.aiChatOnebotv11 || {}
+    return config.onebotv11 || {}
+}
+
+function getOneBotConfig(kind = BOT_KIND_WORKORDER) {
+    const onebot = getRawConfig(kind)
+    const isAiChat = kind === BOT_KIND_AI_CHAT
+    const defaults = isAiChat
+        ? {
+            reverseWsPort: 15701,
+            senderNickname: 'Xiaoyan Assistant Web User',
+            botName: 'Xiaoyan Assistant',
+            botUuid: 'onebot-v11-xiaoyan-chat'
+        }
+        : {
+            reverseWsPort: 15700,
+            senderNickname: 'Ticket System',
+            botName: 'Xiaoyan Assistant',
+            botUuid: 'onebot-v11-xiaoyan-assistant'
+        }
+
     return {
         enabled: onebot.enabled === true,
         transport: onebot.transport || 'reverse_ws',
         reverseWsUrl: onebot.reverseWsUrl || '',
         reverseWsHost: onebot.reverseWsHost || '127.0.0.1',
-        reverseWsPort: Number(onebot.reverseWsPort || 15700),
-        /** AstrBot / aiocqhttp 反向 WS 常见路径为 /ws;根路径 / 常返回 405 */
+        reverseWsPort: Number(onebot.reverseWsPort || defaults.reverseWsPort),
         reverseWsPath: onebot.reverseWsPath || '',
         reverseWsToken: onebot.reverseWsToken || '',
         callbackToken: onebot.callbackToken || '',
-        ticketSenderNickname: onebot.ticketSenderNickname || '工单系统',
+        ticketSenderNickname: onebot.ticketSenderNickname || defaults.senderNickname,
+        chatSenderNickname: onebot.chatSenderNickname || onebot.ticketSenderNickname || defaults.senderNickname,
         selfId: onebot.selfId || '',
-        botName: onebot.botName || '小妍助理',
-        botUuid: onebot.botUuid || 'onebot-v11-xiaoyan-assistant'
+        botName: onebot.botName || defaults.botName,
+        botUuid: onebot.botUuid || defaults.botUuid
     }
 }
 
+function getWorkOrderOneBotConfig() {
+    return getOneBotConfig(BOT_KIND_WORKORDER)
+}
+
+function getAiChatOneBotConfig() {
+    return getOneBotConfig(BOT_KIND_AI_CHAT)
+}
+
 function getReverseWsUrl(onebot) {
     const defaultPath = '/ws'
     const explicitPath = onebot.reverseWsPath
@@ -65,60 +108,78 @@ function getReverseWsUrl(onebot) {
     return `ws://${onebot.reverseWsHost}:${onebot.reverseWsPort}${pathPart}`
 }
 
-function scheduleReconnect() {
-    if (reconnectTimer) return
-    logger.info('OneBot v11 ws 3秒后尝试重连')
-    reconnectTimer = setTimeout(() => {
-        reconnectTimer = null
-        connectReverseWs().catch(err => {
-            logger.error(`OneBot v11 重连失败:${err.message}`)
+function getState(kind) {
+    return botStates[kind] || botStates[BOT_KIND_WORKORDER]
+}
+
+function getBotLabel(kind) {
+    return kind === BOT_KIND_AI_CHAT ? 'AIChat OneBot v11' : 'WorkOrder OneBot v11'
+}
+
+function scheduleReconnect(kind) {
+    const state = getState(kind)
+    if (state.reconnectTimer) return
+    const onebot = getOneBotConfig(kind)
+    logger.info(`${getBotLabel(kind)} ws reconnect scheduled in 3s`)
+    state.reconnectTimer = setTimeout(() => {
+        state.reconnectTimer = null
+        connectReverseWs(kind).catch(err => {
+            logger.error(`${getBotLabel(kind)} reconnect failed: ${err.message}`)
         })
     }, 3000)
+    if (!onebot.enabled && state.reconnectTimer) {
+        clearTimeout(state.reconnectTimer)
+        state.reconnectTimer = null
+    }
 }
 
-function scheduleApiReconnect() {
-    if (apiReconnectTimer) return
-    logger.info('OneBot v11 API ws 3秒后尝试重连')
-    apiReconnectTimer = setTimeout(() => {
-        apiReconnectTimer = null
-        connectApiReverseWs().catch(err => {
-            logger.error(`OneBot v11 API ws 重连失败:${err.message}`)
+function scheduleApiReconnect(kind) {
+    const state = getState(kind)
+    if (state.apiReconnectTimer) return
+    const onebot = getOneBotConfig(kind)
+    logger.info(`${getBotLabel(kind)} API ws reconnect scheduled in 3s`)
+    state.apiReconnectTimer = setTimeout(() => {
+        state.apiReconnectTimer = null
+        connectApiReverseWs(kind).catch(err => {
+            logger.error(`${getBotLabel(kind)} API ws reconnect failed: ${err.message}`)
         })
     }, 3000)
+    if (!onebot.enabled && state.apiReconnectTimer) {
+        clearTimeout(state.apiReconnectTimer)
+        state.apiReconnectTimer = null
+    }
 }
 
-async function connectReverseWs() {
-    if (wsClient && wsClient.readyState === WebSocket.OPEN) return wsClient
-    if (connectingPromise) return connectingPromise
+async function connectReverseWs(kind = BOT_KIND_WORKORDER) {
+    const state = getState(kind)
+    if (state.wsClient && state.wsClient.readyState === WebSocket.OPEN) return state.wsClient
+    if (state.connectingPromise) return state.connectingPromise
 
-    const onebot = getOneBotConfig()
-    if (!onebot.enabled) throw new Error('OneBot v11 未启用')
-    if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
+    const onebot = getOneBotConfig(kind)
+    if (!onebot.enabled) throw new Error(`${getBotLabel(kind)} is disabled`)
+    if (onebot.transport !== 'reverse_ws') throw new Error(`${getBotLabel(kind)} only supports reverse_ws`)
 
     const wsUrl = getReverseWsUrl(onebot)
-    logger.info(`OneBot v11 准备连接 reverse ws:${wsUrl}`)
-    if (!onebot.selfId) {
-        throw new Error('OneBot v11 未配置 selfId:握手头 X-Self-ID 必填')
-    }
+    logger.info(`${getBotLabel(kind)} connecting reverse ws: ${wsUrl}`)
+    if (!onebot.selfId) throw new Error(`${getBotLabel(kind)} missing selfId`)
     const selfIdStr = String(onebot.selfId).trim()
-    if (!selfIdStr) {
-        throw new Error('OneBot v11 selfId 不能为空')
-    }
+    if (!selfIdStr) throw new Error(`${getBotLabel(kind)} selfId is empty`)
+
     const headers = {
-        'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-client)',
-        'X-Client-Role': 'Universal',
+        'User-Agent': `runforge/1.0 (${kind}-onebot-v11-reverse-ws-client)`,
+        'X-Client-Role': `${kind}:event`,
         'X-Self-ID': selfIdStr
     }
-    if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
+    if (onebot.reverseWsToken) headers.Authorization = `Bearer ${onebot.reverseWsToken}`
 
-    connectingPromise = new Promise((resolve, reject) => {
+    state.connectingPromise = new Promise((resolve, reject) => {
         const client = new WebSocket(wsUrl, { headers })
         let opened = false
 
         const connTimeout = setTimeout(() => {
             if (!opened) {
                 try { client.terminate() } catch (_) { }
-                reject(new Error(`连接 OneBot v11 超时:${wsUrl}`))
+                reject(new Error(`${getBotLabel(kind)} connect timeout: ${wsUrl}`))
             }
         }, 5000)
 
@@ -127,22 +188,21 @@ async function connectReverseWs() {
             const statusMessage = res && res.statusMessage
             const rh = res && typeof res.getHeaders === 'function' ? res.getHeaders() : {}
             const rawPreview = res && Array.isArray(res.rawHeaders) ? res.rawHeaders.slice(0, 24) : []
-            logger.error(`OneBot v11 ws unexpected-response: ${statusCode} ${statusMessage}`)
-            logger.error(`OneBot v11 ws response headers: ${stringifySafe({ rh, rawPreview })}`)
+            logger.error(`${getBotLabel(kind)} ws unexpected-response: ${statusCode} ${statusMessage}`)
+            logger.error(`${getBotLabel(kind)} ws response headers: ${stringifySafe({ rh, rawPreview })}`)
         })
 
         client.on('open', () => {
             clearTimeout(connTimeout)
             opened = true
-            wsClient = client
-            logger.info(`OneBot v11 reverse ws 已连接:${wsUrl},selfId=${onebot.selfId || '未配置'}`)
-            const lifecycleConnect = buildLifecycleConnectEvent(onebot)
-            client.send(JSON.stringify(lifecycleConnect), (err) => {
+            state.wsClient = client
+            logger.info(`${getBotLabel(kind)} reverse ws connected: ${wsUrl}, selfId=${onebot.selfId}`)
+            client.send(JSON.stringify(buildLifecycleConnectEvent(onebot)), (err) => {
                 if (err) {
-                    logger.error(`OneBot 生命周期事件发送失败:${err.message}`)
+                    logger.error(`${getBotLabel(kind)} lifecycle event failed: ${err.message}`)
                     return
                 }
-                logger.info('OneBot 生命周期事件已发送:meta_event.lifecycle.connect')
+                logger.info(`${getBotLabel(kind)} lifecycle connect event sent`)
             })
             resolve(client)
         })
@@ -150,96 +210,97 @@ async function connectReverseWs() {
         client.on('error', (err) => {
             clearTimeout(connTimeout)
             if (!opened) reject(err)
-            logger.error(`OneBot v11 ws 异常:${err.message}`)
+            logger.error(`${getBotLabel(kind)} ws error: ${err.message}`)
         })
 
         client.on('close', () => {
-            wsClient = null
-            connectingPromise = null
-            logger.error('OneBot v11 ws 连接已关闭')
-            if (onebot.enabled) scheduleReconnect()
+            state.wsClient = null
+            state.connectingPromise = null
+            logger.error(`${getBotLabel(kind)} ws closed`)
+            if (getOneBotConfig(kind).enabled) scheduleReconnect(kind)
         })
 
         client.on('message', (raw) => {
             const text = typeof raw === 'string' ? raw : raw.toString('utf8')
-            logger.info(`OneBot v11 收到消息:${text.slice(0, 1000)}`)
-            handleWsIncomingFrame(text, client).catch((err) => {
-                logger.error(`OneBot v11 处理收到消息失败:${err.message}`)
+            logger.info(`${getBotLabel(kind)} received: ${text.slice(0, 1000)}`)
+            handleWsIncomingFrame(text, client, kind).catch((err) => {
+                logger.error(`${getBotLabel(kind)} incoming frame failed: ${err.message}`)
             })
         })
     })
 
     try {
-        return await connectingPromise
+        return await state.connectingPromise
     } finally {
-        connectingPromise = null
+        state.connectingPromise = null
     }
 }
 
-async function connectApiReverseWs() {
-    if (wsApiClient && wsApiClient.readyState === WebSocket.OPEN) return wsApiClient
-    if (connectingApiPromise) return connectingApiPromise
+async function connectApiReverseWs(kind = BOT_KIND_WORKORDER) {
+    const state = getState(kind)
+    if (state.wsApiClient && state.wsApiClient.readyState === WebSocket.OPEN) return state.wsApiClient
+    if (state.connectingApiPromise) return state.connectingApiPromise
 
-    const onebot = getOneBotConfig()
-    if (!onebot.enabled) throw new Error('OneBot v11 未启用')
-    if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
+    const onebot = getOneBotConfig(kind)
+    if (!onebot.enabled) throw new Error(`${getBotLabel(kind)} is disabled`)
+    if (onebot.transport !== 'reverse_ws') throw new Error(`${getBotLabel(kind)} only supports reverse_ws`)
 
     const wsUrl = getReverseWsUrl(onebot)
-    if (!onebot.selfId) throw new Error('API ws 缺少 selfId')
+    if (!onebot.selfId) throw new Error(`${getBotLabel(kind)} API ws missing selfId`)
     const selfIdStr = String(onebot.selfId).trim()
-    if (!selfIdStr) throw new Error('API ws selfId 不能为空')
+    if (!selfIdStr) throw new Error(`${getBotLabel(kind)} API ws selfId is empty`)
 
     const headers = {
-        'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-api-client)',
-        'X-Client-Role': 'Api',
+        'User-Agent': `runforge/1.0 (${kind}-onebot-v11-reverse-ws-api-client)`,
+        'X-Client-Role': `${kind}:api`,
         'X-Self-ID': selfIdStr
     }
-    if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
+    if (onebot.reverseWsToken) headers.Authorization = `Bearer ${onebot.reverseWsToken}`
 
-    connectingApiPromise = new Promise((resolve, reject) => {
+    state.connectingApiPromise = new Promise((resolve, reject) => {
         const client = new WebSocket(wsUrl, { headers })
         let opened = false
         const connTimeout = setTimeout(() => {
             if (!opened) {
                 try { client.terminate() } catch (_) { }
-                reject(new Error(`连接 OneBot v11 API ws 超时:${wsUrl}`))
+                reject(new Error(`${getBotLabel(kind)} API ws connect timeout: ${wsUrl}`))
             }
         }, 5000)
 
         client.on('open', () => {
             clearTimeout(connTimeout)
             opened = true
-            wsApiClient = client
-            logger.info(`OneBot v11 API ws 已连接:${wsUrl}`)
+            state.wsApiClient = client
+            logger.info(`${getBotLabel(kind)} API ws connected: ${wsUrl}`)
             resolve(client)
         })
 
         client.on('error', (err) => {
             clearTimeout(connTimeout)
             if (!opened) reject(err)
-            logger.error(`OneBot v11 API ws 异常:${err.message}`)
+            logger.error(`${getBotLabel(kind)} API ws error: ${err.message}`)
         })
 
         client.on('close', () => {
-            wsApiClient = null
-            connectingApiPromise = null
-            logger.error('OneBot v11 API ws 连接已关闭')
-            if (onebot.enabled) scheduleApiReconnect()
+            state.wsApiClient = null
+            state.connectingApiPromise = null
+            logger.error(`${getBotLabel(kind)} API ws closed`)
+            if (getOneBotConfig(kind).enabled) scheduleApiReconnect(kind)
         })
 
         client.on('message', (raw) => {
             const text = typeof raw === 'string' ? raw : raw.toString('utf8')
-            logger.info(`OneBot v11 API ws 收到消息:${text.slice(0, 1000)}`)
-            handleWsIncomingFrame(text, client).catch((err) => {
-                logger.error(`OneBot v11 API ws 消息处理失败:${err.message}`)
+            logger.info(`${getBotLabel(kind)} API ws received: ${text.slice(0, 1000)}`)
+            handleWsIncomingFrame(text, client, kind).catch((err) => {
+                logger.error(`${getBotLabel(kind)} API frame failed: ${err.message}`)
             })
         })
     })
 
     try {
-        return await connectingApiPromise
+        return await state.connectingApiPromise
     } finally {
-        connectingApiPromise = null
+        state.connectingApiPromise = null
     }
 }
 
@@ -248,9 +309,8 @@ function buildSyntheticPrivateMessageEvent(text, onebot, orderId) {
     const selfId = String(onebot.selfId).trim()
     const senderIdStr = String(orderId || '').trim()
     if (!senderIdStr || !/^\d+$/.test(senderIdStr) || senderIdStr === selfId) {
-        throw new Error('orderId 无效,无法作为 OneBot user_id')
+        throw new Error('Invalid orderId for OneBot user_id')
     }
-    // AstrBot aiocqhttp 消息链路要求 message 为消息段数组(字符串会走失败分支)
     const messageArr = [{ type: 'text', data: { text } }]
     const messageId = Math.floor(Math.random() * 2000000000) + 1
     return {
@@ -266,7 +326,52 @@ function buildSyntheticPrivateMessageEvent(text, onebot, orderId) {
         font: 0,
         sender: {
             user_id: senderIdStr,
-            nickname: onebot.ticketSenderNickname || '工单系统',
+            nickname: onebot.ticketSenderNickname || 'Ticket System',
+            sex: 'unknown',
+            age: 0
+        }
+    }
+}
+
+function buildSyntheticAiChatMessageEvent(payload, onebot) {
+    const now = Math.floor(Date.now() / 1000)
+    const selfId = String(onebot.selfId).trim()
+    const senderIdStr = String(payload.conversationNo || payload.conversationId || '').trim()
+    if (!senderIdStr || !/^\d+$/.test(senderIdStr) || senderIdStr === selfId) {
+        throw new Error('Invalid conversationId for OneBot user_id')
+    }
+
+    const lines = [
+        `[AIChat#${senderIdStr}]`,
+        `user_uuid: ${payload.senderUuid || '-'}`,
+        'content:',
+        payload.content || ''
+    ]
+    const messageArr = [{ type: 'text', data: { text: lines.join('\n') } }]
+    for (const url of payload.images || []) {
+        messageArr.push({ type: 'image', data: { file: url } })
+    }
+
+    const rawImages = (payload.images || []).length > 0
+        ? `\nimages:\n${(payload.images || []).join('\n')}`
+        : ''
+    const rawMessage = lines.join('\n') + rawImages
+    const messageId = Math.floor(Math.random() * 2000000000) + 1
+
+    return {
+        time: now,
+        self_id: selfId,
+        post_type: 'message',
+        message_type: 'private',
+        sub_type: 'friend',
+        message_id: messageId,
+        user_id: senderIdStr,
+        message: messageArr,
+        raw_message: rawMessage,
+        font: 0,
+        sender: {
+            user_id: senderIdStr,
+            nickname: onebot.chatSenderNickname || 'Xiaoyan Assistant Web User',
             sex: 'unknown',
             age: 0
         }
@@ -283,17 +388,17 @@ function buildLifecycleConnectEvent(onebot) {
     }
 }
 
-async function sendImplementationEvent(eventObj) {
-    const client = await connectReverseWs()
+async function sendImplementationEvent(eventObj, kind = BOT_KIND_WORKORDER) {
+    const client = await connectReverseWs(kind)
     const line = stringifySafe(eventObj)
-    logger.info(`OneBot v11 上报事件 post_type=${eventObj.post_type} message_type=${eventObj.message_type} len=${line.length}`)
+    logger.info(`${getBotLabel(kind)} reporting event post_type=${eventObj.post_type} message_type=${eventObj.message_type} len=${line.length}`)
     return new Promise((resolve, reject) => {
         client.send(line, (err) => {
             if (err) {
-                logger.error(`OneBot 事件发送失败:${err.message}`)
+                logger.error(`${getBotLabel(kind)} event send failed: ${err.message}`)
                 return reject(err)
             }
-            logger.info('OneBot v11 事件已写入 WebSocket')
+            logger.info(`${getBotLabel(kind)} event written to WebSocket`)
             resolve(true)
         })
     })
@@ -313,12 +418,11 @@ function decodeMessageText(message) {
 }
 
 async function persistAiReplyAsServerReply(orderId, plainText) {
-    const onebot = getOneBotConfig()
     const parsedOrderId = Number(orderId)
-    if (!Number.isInteger(parsedOrderId) || parsedOrderId <= 0) return
+    if (!Number.isInteger(parsedOrderId) || parsedOrderId <= 0) return false
 
     const rows = await db.query('SELECT msg, state, email FROM work_order WHERE id = ?', [parsedOrderId])
-    if (!rows || rows.length !== 1 || rows[0].state === 2) return
+    if (!rows || rows.length !== 1 || rows[0].state === 2) return false
 
     const now = Date.now()
     const msg = rows[0].msg || []
@@ -335,9 +439,38 @@ async function persistAiReplyAsServerReply(orderId, plainText) {
     if (rows[0].email) {
         await EmailTemplate.orderNewReply(rows[0].email, { id: parsedOrderId, content: plainText, files: [] })
     }
+    return true
 }
 
-async function handleWsIncomingFrame(text, client) {
+async function persistAiChatReply(conversationId, plainText) {
+    const parsedConversationNo = String(conversationId || '').trim()
+    if (!/^\d+$/.test(parsedConversationNo)) return false
+    return await ConversationService.addAssistantMessage({
+        conversationNo: parsedConversationNo,
+        content: plainText
+    })
+}
+
+function ackOneBotApiRequest(j, client, kind) {
+    if (j.action === undefined || j.echo === undefined || typeof j.echo !== 'object' || j.echo === null || j.echo.seq === undefined) {
+        return false
+    }
+    const resp = {
+        status: 'ok',
+        retcode: 0,
+        data: null,
+        echo: j.echo
+    }
+    try {
+        client.send(JSON.stringify(resp))
+        logger.info(`${getBotLabel(kind)} API request acked: action=${j.action} seq=${JSON.stringify(j.echo.seq)}`)
+    } catch (err) {
+        logger.error(`${getBotLabel(kind)} API ack failed: ${err.message}`)
+    }
+    return true
+}
+
+async function handleWsIncomingFrame(text, client, kind = BOT_KIND_WORKORDER) {
     let j
     try {
         j = JSON.parse(text)
@@ -348,38 +481,23 @@ async function handleWsIncomingFrame(text, client) {
 
     if (j.action === 'send_private_msg' && j.params) {
         const plainText = decodeMessageText(j.params.message)
-        logger.info(`OneBot v11 收到中文消息(解码):${plainText}`)
-        await persistAiReplyAsServerReply(j.params.user_id, plainText)
-    }
-
-    /**
-     * 反向 WS 上「应用端 → 实现端」的 API 请求(aiocqhttp 使用 echo: { seq })。
-     * 若不回包,AstrBot 侧 ResultStore 会超时,事件处理卡住,表现为服务端「收不到」工单事件。
-     */
-    if (j.action !== undefined && j.echo !== undefined && typeof j.echo === 'object' && j.echo !== null && j.echo.seq !== undefined) {
-        const resp = {
-            status: 'ok',
-            retcode: 0,
-            data: null,
-            echo: j.echo
-        }
-        try {
-            client.send(JSON.stringify(resp))
-            logger.info(`OneBot v11 已应答应用端 API:action=${j.action} seq=${JSON.stringify(j.echo.seq)}`)
-        } catch (err) {
-            logger.error(`OneBot v11 应答 API 失败:${err.message}`)
+        logger.info(`${getBotLabel(kind)} decoded private message: ${plainText}`)
+        if (kind === BOT_KIND_AI_CHAT) {
+            await persistAiChatReply(j.params.user_id, plainText)
+        } else {
+            await persistAiReplyAsServerReply(j.params.user_id, plainText)
         }
-        return
     }
 
+    if (ackOneBotApiRequest(j, client, kind)) return
     if ('post_type' in j) return
 
     if (j.echo !== undefined && j.status !== undefined) {
         if (j.retcode !== 0 && j.retcode !== undefined) {
             const wording = j.wording || j.message || stringifySafe(j.data)
-            logger.error(`OneBot API 返回失败 echo=${j.echo} retcode=${j.retcode} ${wording}`)
+            logger.error(`${getBotLabel(kind)} API failed echo=${j.echo} retcode=${j.retcode} ${wording}`)
         } else {
-            logger.info(`OneBot API 成功 echo=${j.echo}`)
+            logger.info(`${getBotLabel(kind)} API success echo=${j.echo}`)
         }
     }
 }
@@ -394,50 +512,84 @@ function buildOrderText(payload) {
         senderUuid
     } = payload
 
-    const fileText = files.length > 0 ? `\n附件:\n${files.join('\n')}` : ''
+    const fileText = files.length > 0 ? `\nattachments:\n${files.join('\n')}` : ''
     return [
-        `[工单#${orderId}] ${title ? title : '工单消息更新'}`,
-        `发送方:${role === '用户' ? '用户' : '系统'}`,
-        `用户uuid:${senderUuid || '-'}`,
-        '消息内容:',
+        `[Ticket#${orderId}] ${title ? title : 'Ticket message updated'}`,
+        `sender: ${role === '用户' ? 'user' : 'system'}`,
+        `user_uuid: ${senderUuid || '-'}`,
+        'content:',
         content
     ].join('\n') + fileText
 }
 
 async function sendOrderMessage(payload) {
-    const onebot = getOneBotConfig()
+    const onebot = getWorkOrderOneBotConfig()
     if (!onebot.enabled) return false
     if (onebot.transport !== 'reverse_ws') {
-        logger.error('OneBot v11 传输模式错误,仅支持 reverse_ws')
+        logger.error('WorkOrder OneBot v11 transport error: only reverse_ws is supported')
         return false
     }
 
     const text = buildOrderText(payload)
-
     const eventObj = buildSyntheticPrivateMessageEvent(text, onebot, payload.orderId)
-    await sendImplementationEvent(eventObj)
-    logger.info(`OneBot 工单事件已上报:orderId=${payload.orderId}, userId=${eventObj.user_id}, messageId=${eventObj.message_id}`)
+    await sendImplementationEvent(eventObj, BOT_KIND_WORKORDER)
+    logger.info(`WorkOrder OneBot event reported: orderId=${payload.orderId}, userId=${eventObj.user_id}, messageId=${eventObj.message_id}`)
     return true
 }
 
-async function initOneBotWs() {
-    const onebot = getOneBotConfig()
+async function sendAiChatMessage(payload) {
+    const onebot = getAiChatOneBotConfig()
+    if (!onebot.enabled) throw new Error('AIChat OneBot v11 is disabled')
+    if (onebot.transport !== 'reverse_ws') {
+        logger.error('AIChat OneBot v11 transport error: only reverse_ws is supported')
+        throw new Error('AIChat OneBot v11 transport error')
+    }
+
+    const eventObj = buildSyntheticAiChatMessageEvent(payload, onebot)
+    await sendImplementationEvent(eventObj, BOT_KIND_AI_CHAT)
+    logger.info(`AIChat OneBot event reported: conversationId=${payload.conversationId}, conversationNo=${payload.conversationNo}, userId=${eventObj.user_id}, messageId=${eventObj.message_id}`)
+    return true
+}
+
+async function initOneBotKind(kind) {
+    const onebot = getOneBotConfig(kind)
     if (!onebot.enabled) {
-        logger.info('OneBot v11 未启用,跳过 ws 初始化')
+        logger.info(`${getBotLabel(kind)} disabled, skip ws init`)
         return false
     }
     if (onebot.transport !== 'reverse_ws') {
-        logger.error('OneBot v11 初始化失败:传输模式不是 reverse_ws')
+        logger.error(`${getBotLabel(kind)} init failed: transport is not reverse_ws`)
         return false
     }
 
-    await connectReverseWs()
-    await connectApiReverseWs()
+    await connectReverseWs(kind)
+    await connectApiReverseWs(kind)
     return true
 }
 
+async function initOneBotWs() {
+    const results = await Promise.allSettled([
+        initOneBotKind(BOT_KIND_WORKORDER),
+        initOneBotKind(BOT_KIND_AI_CHAT)
+    ])
+
+    let anyReady = false
+    for (const [idx, result] of results.entries()) {
+        const kind = idx === 0 ? BOT_KIND_WORKORDER : BOT_KIND_AI_CHAT
+        if (result.status === 'fulfilled') {
+            anyReady = anyReady || result.value === true
+            continue
+        }
+        logger.error(`${getBotLabel(kind)} init failed: ${result.reason && result.reason.message ? result.reason.message : result.reason}`)
+    }
+    return anyReady
+}
+
 module.exports = {
     sendOrderMessage,
+    sendAiChatMessage,
     getOneBotConfig,
+    getWorkOrderOneBotConfig,
+    getAiChatOneBotConfig,
     initOneBotWs
 }