const http = require('http') const https = require('https') const fs = require('fs') const path = require('path') loadEnv(path.join(__dirname, '.env')) const PORT = Number(process.env.PORT || 8787) const MES_BASE_URL = process.env.MES_BASE_URL || 'http://127.0.0.1:10100' const DEEPSEEK_BASE_URL = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com' const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash' const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '' const MES_AI_ALLOW_WRITE = process.env.MES_AI_ALLOW_WRITE === '1' const CATALOG_PATH = path.join(__dirname, 'catalog', 'mes-capabilities.json') const catalog = JSON.parse(fs.readFileSync(CATALOG_PATH, 'utf8')) const frontendTaskMap = readJsonIfExists(path.join(__dirname, 'catalog', 'frontend-task-map.json'), []) const knowledgeBase = loadKnowledgeBase() const generatedKnowledge = loadGeneratedKnowledge() const tools = [ { name: 'mes.list_capabilities', description: '列出 HL_MES_manager 的模块、页面和功能能力。', input_schema: { type: 'object', properties: { module: { type: 'string', description: '可选,按模块名模糊过滤。' } } } }, { name: 'mes.search_capabilities', description: '按关键词搜索 MES 功能目录。', input_schema: { type: 'object', properties: { keyword: { type: 'string', description: '关键词,如 质量、BOM、OEE、Andon。' } }, required: ['keyword'] } }, { name: 'kb.list_documents', description: '列出 AI 知识库中的文档。', input_schema: { type: 'object', properties: {} } }, { name: 'kb.search', description: '检索 AI 知识库,适合查询功能、数据库、接口、PLC、部署和风险。', input_schema: { type: 'object', properties: { keyword: { type: 'string', description: '检索关键词,如 质量数据、MES_计划BOM、OP010、DeepSeek。' }, limit: { type: 'number', description: '返回条数,默认 5。' } }, required: ['keyword'] } }, { name: 'kb.read_document', description: '读取知识库文档全文或前 N 个字符。', input_schema: { type: 'object', properties: { id: { type: 'string', description: '文档 ID。' }, maxLength: { type: 'number', description: '最大字符数,默认 12000。' } }, required: ['id'] } }, { name: 'frontend.search_pages', description: '搜索前端页面、按钮、导出能力和接口调用,适合回答页面操作问题。', input_schema: { type: 'object', properties: { keyword: { type: 'string', description: '页面、按钮、字段、接口关键词。' }, limit: { type: 'number', description: '返回条数,默认 8。' } }, required: ['keyword'] } }, { name: 'mes.query', description: '通过 MESCommonBase.ashx 调用只读查询。仅允许 type=1 或 3。', input_schema: { type: 'object', properties: { type: { type: 'string', enum: ['1', '3'] }, name: { type: 'string' }, param: { type: 'string' } }, required: ['type', 'name'] } }, { name: 'mes.execute', description: '执行 MES 通用接口。默认禁止写入类操作,需 MES_AI_ALLOW_WRITE=1。', input_schema: { type: 'object', properties: { type: { type: 'string' }, name: { type: 'string' }, param: { type: 'string' }, pageSize: {}, pageList: {} }, required: ['type', 'name'] } } ] const server = http.createServer(async(req, res) => { setCors(res) if (req.method === 'OPTIONS') { return sendJson(res, 204, {}) } try { const url = new URL(req.url, `http://${req.headers.host}`) if (req.method === 'GET' && url.pathname === '/health') { return sendJson(res, 200, { ok: true, service: 'hl-mes-ai-mcp-service', mesBaseUrl: MES_BASE_URL, model: DEEPSEEK_MODEL, deepseekConfigured: Boolean(DEEPSEEK_API_KEY), knowledgeDocuments: knowledgeBase.index.length }) } if (req.method === 'GET' && url.pathname === '/api/mcp/tools') { return sendJson(res, 200, { tools }) } if (req.method === 'GET' && url.pathname === '/api/kb/list') { return sendJson(res, 200, { documents: knowledgeBase.index }) } if (req.method === 'GET' && url.pathname === '/api/kb/search') { return sendJson(res, 200, { results: searchKnowledgeBase(url.searchParams.get('q') || '', Number(url.searchParams.get('limit') || 5)) }) } if (req.method === 'GET' && url.pathname === '/api/kb/read') { return sendJson(res, 200, readKnowledgeDocument(url.searchParams.get('id') || '', Number(url.searchParams.get('maxLength') || 12000))) } if (req.method === 'POST' && url.pathname === '/api/mcp/call') { const body = await readJson(req) const result = await callTool(body.name, body.arguments || {}) return sendJson(res, 200, { result }) } if (req.method === 'POST' && url.pathname === '/api/ai/chat') { const body = await readJson(req) const answer = await chatWithDeepSeek(body.messages || [], body.context || {}) return sendJson(res, 200, answer) } sendJson(res, 404, { error: 'Not found' }) } catch (error) { sendJson(res, 500, { error: error.message || String(error) }) } }) server.listen(PORT, '127.0.0.1', () => { console.log(`HL MES AI MCP service listening on http://127.0.0.1:${PORT}`) }) async function chatWithDeepSeek(messages, context) { if (!DEEPSEEK_API_KEY) { return { message: '
状态处理方式
AI 服务未配置 DEEPSEEK_API_KEY请在 ai-mcp-service/.env 中填写后重启服务。
', format: 'html', toolResults: [] } } const toolResults = prefetchLocalContext(messages, context) const systemPrompt = [ '你是 HL_MES_manager 前端系统内置 AI 助手,不是通用聊天机器人。', '你的职责只限于帮助用户使用当前前端网页:打开模块、解释页面功能、说明查询/导出步骤、生成本系统相关表格、解释 MES 接口和排查本系统问题。', '禁止回答与 HL_MES_manager 前端程序无关的问题。遇到无关问题,必须简短说明“我只能协助当前 MES 前端系统相关操作”,并引导用户提出页面、模块、查询、导出或排查问题。', '回答必须优先基于当前用户可见菜单、当前路由、MES 功能目录、前端任务映射、接口约束和知识库检索结果。', '遇到“系统日志、报警日志、扫码记录、过站日志、点检记录、导出”等词时,必须优先按前端任务映射定位项目内页面,不要扩展成操作系统日志、服务器日志或通用审计场景。', '不要假装已经点击页面、读取实时页面数据或完成导出;实际页面跳转、导出由前端执行。', '你可以解释模块位置、业务流程、页面查询条件、导出按钮、接口调用方式、排查方向。', '涉及写入、删除、下发、修改数据时,必须提醒用户需要人工确认,不要直接执行。', '输出协议:所有回答必须直接以标准 HTML 片段作为主格式输出。', '不要使用 Markdown 语法,不要使用 ``` 代码围栏,不要输出纯文本段落,不要输出完整 html/head/body 标签。', '输出组织规则:每次回答必须至少包含一个规范 HTML table,方便用户查看。', '即使是简短提示,也要用两列表格展示,表头可为“项目/内容”或“操作/说明”。', '模块清单、字段说明、接口参数、页面位置、操作步骤、导出字段、排查项、对比项、结果数据,都优先使用 ' }) return '
。', '可以在表格前用一个

给出一句结论,但主体必须是 table。', '简单步骤也优先改成表格,不要使用普通项目符号列表作为主体。', '重点使用 ,路径/接口/字段使用 ,多行示例使用

。',
    '不要输出 JavaScript 代码、script 标签或事件属性;需要表格时只输出静态 HTML table,由前端负责显示和导出。',
    '允许标签:p、ul、ol、li、table、thead、tbody、tr、th、td、strong、em、code、pre、h3、h4、svg、g、rect、line、polyline、circle、text、path。',
    '图文并茂规则:当回答流程、模块关系、数据流、PLC/MQTT 交互、查询导出步骤时,优先在表格后补充一个简洁内联 SVG 图示;SVG 必须是静态图,不得包含脚本、动画或外链。',
    '禁止标签:script、style、iframe、form、input、button、link、meta、image、foreignObject。',
    '禁止任何 on* 事件属性、javascript: URL、内联脚本。',
    '当用户要求制作 Excel、表格、清单、台账、模板时,必须输出可被前端导出的 HTML table;表头用 th,数据行用 td。',
    '如果用户要求打开某个模块或页面,只说明目标模块名称和建议页面;实际跳转由前端根据当前用户菜单执行。',
    '不要输出 script、style、iframe、form、input、button,不要输出任何 on* 事件属性。',
    '当前前端上下文如下:',
    JSON.stringify(normalizeClientContext(context)),
    '当前可用 MCP 风格工具如下:',
    JSON.stringify(tools.map(tool => ({ name: tool.name, description: tool.description }))),
    '当前知识库文档如下:',
    JSON.stringify(knowledgeBase.index.map(item => ({ id: item.id, title: item.title, category: item.category, summary: item.summary }))),
    '页面级索引规模如下:',
    JSON.stringify({ pages: generatedKnowledge.pages.length, apiCalls: generatedKnowledge.apiCalls.length }),
    '前端任务映射如下:',
    JSON.stringify(frontendTaskMap),
    'MES 功能目录如下:',
    JSON.stringify(catalog)
  ].join('\n')

  const data = await requestJson(`${DEEPSEEK_BASE_URL.replace(/\/$/, '')}/chat/completions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${DEEPSEEK_API_KEY}`
    },
    body: {
      model: DEEPSEEK_MODEL,
      messages: [
        { role: 'system', content: systemPrompt },
        ...toolResults.map(item => ({
          role: 'system',
          content: `本地工具 ${item.name} 结果:${JSON.stringify(item.result)}`
        })),
        ...messages.slice(-12)
      ],
      temperature: 0.2,
      stream: false,
      response_format: {
        type: 'text'
      }
    }
  })

  if (data.statusCode < 200 || data.statusCode >= 300) {
    const payload = data.body
    throw new Error(payload.error && payload.error.message ? payload.error.message : `DeepSeek request failed: ${data.statusCode}`)
  }

  return {
    message: ensureHtmlMessage(data.body.choices && data.body.choices[0] && data.body.choices[0].message ? data.body.choices[0].message.content : ''),
    format: 'html',
    model: data.body.model || DEEPSEEK_MODEL,
    toolResults
  }
}

function ensureHtmlMessage(message) {
  const content = String(message || '').trim()
  if (!content) return '
项目内容
结果AI 服务未返回内容。
' if (/<(p|ul|ol|li|table|thead|tbody|tr|th|td|strong|em|code|pre|h3|h4|svg)[\s>]/i.test(content)) { if (/]/i.test(content)) return content return convertHtmlFragmentToTable(content) } return convertPlainTextToHtml(content) } function convertPlainTextToHtml(content) { const lines = String(content || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean) if (!lines.length) return '
项目内容
结果AI 服务未返回内容。
' const rows = lines.map((line, index) => { const normalized = line .replace(/^#{1,4}\s+/, '') .replace(/^[-*]\s+/, '') .replace(/^\d+[.)]\s+/, '') return '
' + (index + 1) + '' + escapeHtml(normalized) + '
' + rows.join('') + '
序号内容
' } function convertHtmlFragmentToTable(content) { const blocks = [] const blockRegex = /<(h3|h4|p|li|pre)[^>]*>([\s\S]*?)<\/\1>/gi let match while ((match = blockRegex.exec(content))) { const typeMap = { h3: '标题', h4: '标题', p: '说明', li: '条目', pre: '示例' } const value = match[2].trim() if (value) { blocks.push({ type: typeMap[match[1].toLowerCase()] || '内容', value }) } } if (!blocks.length) { const text = stripHtml(content) return '
项目内容
说明' + escapeHtml(text) + '
' } const rows = blocks.map(item => '' + escapeHtml(item.type) + '' + item.value + '') return '' + rows.join('') + '
类型内容
' } function stripHtml(value) { return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function prefetchLocalContext(messages, context) { const last = messages.length ? String(messages[messages.length - 1].content || '') : '' const results = [] if (last) { results.push({ name: 'mes.search_capabilities', result: searchCapabilities(last) }) results.push({ name: 'kb.search', result: searchKnowledgeBase(last, 5) }) results.push({ name: 'frontend.task_map', result: searchFrontendTaskMap(last) }) results.push({ name: 'frontend.search_pages', result: searchFrontendPages(last, 8) }) const contextMatches = searchClientMenus(last, context) if (contextMatches.length) { results.push({ name: 'frontend.current_menu_match', result: contextMatches }) } } return results } async function callTool(name, args) { if (name === 'mes.list_capabilities') { if (!args.module) return catalog.modules const keyword = String(args.module).toLowerCase() return catalog.modules.filter(item => item.name.toLowerCase().includes(keyword)) } if (name === 'mes.search_capabilities') { return searchCapabilities(args.keyword || '') } if (name === 'kb.list_documents') { return knowledgeBase.index } if (name === 'kb.search') { return searchKnowledgeBase(args.keyword || '', Number(args.limit || 5)) } if (name === 'kb.read_document') { return readKnowledgeDocument(args.id || '', Number(args.maxLength || 12000)) } if (name === 'frontend.search_pages') { return searchFrontendPages(args.keyword || '', Number(args.limit || 8)) } if (name === 'mes.query') { const type = String(args.type || '1') if (type !== '1' && type !== '3') { throw new Error('mes.query only allows type=1 or type=3') } return callMesCommonBase({ type, name: args.name, param: args.param || '' }) } if (name === 'mes.execute') { const type = String(args.type || '') if (!MES_AI_ALLOW_WRITE && type !== '1' && type !== '3') { throw new Error('Write operations are disabled. Set MES_AI_ALLOW_WRITE=1 to enable them.') } return callMesCommonBase(args) } throw new Error(`Unknown tool: ${name}`) } function searchCapabilities(keyword) { const key = String(keyword || '').trim().toLowerCase() if (!key) return [] return catalog.modules .map(module => { const haystack = JSON.stringify(module).toLowerCase() if (!haystack.includes(key)) return null return module }) .filter(Boolean) } function searchFrontendTaskMap(keyword) { const key = String(keyword || '').toLowerCase() if (!key) return [] return frontendTaskMap .map(item => { const haystack = JSON.stringify(item).toLowerCase() let score = haystack.includes(key) ? 20 : 0 item.keywords.forEach(word => { if (key.includes(String(word).toLowerCase()) || haystack.includes(key)) score += 5 }) return score ? Object.assign({ score }, item) : null }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, 5) } function searchClientMenus(keyword, context) { const key = String(keyword || '').toLowerCase() const menus = (context && Array.isArray(context.menus)) ? context.menus : [] if (!key || !menus.length) return [] return menus .map(menu => { const title = String(menu.title || '').toLowerCase() let score = 0 if (title && key.includes(title)) score += 40 if (title && title.includes(key)) score += 30 for (let i = 0; i < key.length; i++) { if (title.includes(key[i])) score += 1 } return score ? Object.assign({ score }, menu) : null }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, 8) } function searchFrontendPages(keyword, limit) { const key = String(keyword || '').toLowerCase() if (!key) return [] const tokens = createSearchTokens(key) const meaningfulTokens = tokens.filter(token => token && token.length > 1) const max = Math.max(1, Math.min(Number(limit) || 8, 20)) return generatedKnowledge.pages .map(page => { const haystack = JSON.stringify(page).toLowerCase() let score = haystack.includes(key) ? 30 : 0 key.split(/[\s,,。;;、]+/).forEach(part => { if (part && haystack.includes(part)) score += 15 }) meaningfulTokens.forEach(token => { if (token && haystack.includes(token)) score += token.length > 1 ? 4 : 1 }) if (/导出|excel|下载|报表/i.test(key) && page.exports && page.exports.length) score += 12 if (/日志|记录/i.test(key) && /日志|记录/i.test(haystack)) score += 12 if (!score) return null return { module: page.module, title: page.title, route: page.route, file: page.file, buttons: page.buttons, exports: page.exports, tableColumns: (page.tableColumns || []).slice(0, 20), score } }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, max) } function normalizeClientContext(context) { const safeContext = context && typeof context === 'object' ? context : {} const menus = Array.isArray(safeContext.menus) ? safeContext.menus.slice(0, 200).map(menu => ({ title: menu.title, path: menu.path, parentTitle: menu.parentTitle })) : [] return { route: safeContext.route || '', routeName: safeContext.routeName || '', userName: safeContext.userName || '', menus } } function readJsonIfExists(file, fallback) { if (!fs.existsSync(file)) return fallback return JSON.parse(fs.readFileSync(file, 'utf8')) } function loadGeneratedKnowledge() { const generatedDir = path.join(__dirname, 'knowledge', 'generated') return { pages: readJsonIfExists(path.join(generatedDir, 'page-catalog.json'), []), apiCalls: readJsonIfExists(path.join(generatedDir, 'api-calls.json'), []), databaseInventory: readJsonIfExists(path.join(generatedDir, 'database-inventory.json'), []) } } function loadKnowledgeBase() { const kbDir = path.join(__dirname, 'knowledge') const indexPath = path.join(kbDir, 'index.json') if (!fs.existsSync(indexPath)) { return { index: [], documents: [] } } const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')) const documents = index.map(item => { const filePath = path.join(kbDir, item.file) const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '' return Object.assign({}, item, { content }) }) return { index, documents } } function searchKnowledgeBase(keyword, limit) { const query = String(keyword || '').trim().toLowerCase() if (!query) return [] const tokens = createSearchTokens(query) const max = Math.max(1, Math.min(Number(limit) || 5, 10)) return knowledgeBase.documents .map(doc => { const haystack = [ doc.id, doc.title, doc.category, (doc.tags || []).join(' '), doc.summary, doc.content ].join('\n').toLowerCase() let score = haystack.includes(query) ? 20 : 0 tokens.forEach(token => { if (token && haystack.includes(token)) score += token.length > 1 ? 3 : 1 }) if (!score) return null return { id: doc.id, title: doc.title, category: doc.category, tags: doc.tags, summary: doc.summary, score, excerpt: makeExcerpt(doc.content, query, tokens) } }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, max) } function readKnowledgeDocument(id, maxLength) { const doc = knowledgeBase.documents.find(item => item.id === id) if (!doc) { return { error: `Knowledge document not found: ${id}` } } const size = Math.max(1000, Math.min(Number(maxLength) || 12000, 50000)) return { id: doc.id, title: doc.title, category: doc.category, tags: doc.tags, summary: doc.summary, content: doc.content.slice(0, size), truncated: doc.content.length > size } } function createSearchTokens(query) { const tokens = new Set() query.split(/[\s,,。;;、]+/).forEach(token => { if (token) tokens.add(token) }) if (/[\u4e00-\u9fa5]/.test(query)) { for (let i = 0; i < query.length - 1; i++) { const token = query.slice(i, i + 2) if (/[\u4e00-\u9fa5]{2}/.test(token)) tokens.add(token) } } return Array.from(tokens) } function makeExcerpt(content, query, tokens) { const lower = content.toLowerCase() let index = lower.indexOf(query) if (index === -1) { for (const token of tokens) { index = lower.indexOf(token) if (index !== -1) break } } if (index === -1) return content.slice(0, 300) const start = Math.max(0, index - 120) const end = Math.min(content.length, index + 280) return content.slice(start, end) } async function callMesCommonBase(payload) { const response = await requestText(`${MES_BASE_URL.replace(/\/$/, '')}/submit/MESCommonBase.ashx`, { method: 'POST', body: payload }) try { return JSON.parse(response.body) } catch (error) { return response.body } } function requestJson(url, options) { return requestText(url, options).then(response => { try { return { statusCode: response.statusCode, headers: response.headers, body: JSON.parse(response.body || '{}') } } catch (error) { return { statusCode: response.statusCode, headers: response.headers, body: { error: { message: response.body } } } } }) } function requestText(url, options) { return new Promise((resolve, reject) => { const target = new URL(url) const transport = target.protocol === 'https:' ? https : http const body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body || {}) const headers = Object.assign({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, options.headers || {}) const req = transport.request({ protocol: target.protocol, hostname: target.hostname, port: target.port, path: `${target.pathname}${target.search}`, method: options.method || 'GET', headers }, res => { let raw = '' res.setEncoding('utf8') res.on('data', chunk => { raw += chunk }) res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: raw }) }) }) req.on('error', reject) req.setTimeout(120000, () => { req.destroy(new Error('Upstream request timeout')) }) if (body) req.write(body) req.end() }) } function setCors(res) { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') } function sendJson(res, statusCode, data) { res.statusCode = statusCode res.setHeader('Content-Type', 'application/json; charset=utf-8') if (statusCode === 204) return res.end() res.end(JSON.stringify(data)) } function readJson(req) { return new Promise((resolve, reject) => { let raw = '' req.on('data', chunk => { raw += chunk if (raw.length > 1024 * 1024) { reject(new Error('Request body too large')) req.destroy() } }) req.on('end', () => { if (!raw) return resolve({}) try { resolve(JSON.parse(raw)) } catch (error) { reject(new Error('Invalid JSON body')) } }) req.on('error', reject) }) } function loadEnv(filePath) { if (!fs.existsSync(filePath)) return const content = fs.readFileSync(filePath, 'utf8') content.split(/\r?\n/).forEach(line => { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) return const index = trimmed.indexOf('=') if (index === -1) return const key = trimmed.slice(0, index).trim() const value = trimmed.slice(index + 1).trim() if (!process.env[key]) process.env[key] = value }) }