730 lines
25 KiB
JavaScript
730 lines
25 KiB
JavaScript
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: '<table><thead><tr><th>状态</th><th>处理方式</th></tr></thead><tbody><tr><td>AI 服务未配置 <code>DEEPSEEK_API_KEY</code></td><td>请在 <code>ai-mcp-service/.env</code> 中填写后重启服务。</td></tr></tbody></table>',
|
||
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,方便用户查看。',
|
||
'即使是简短提示,也要用两列表格展示,表头可为“项目/内容”或“操作/说明”。',
|
||
'模块清单、字段说明、接口参数、页面位置、操作步骤、导出字段、排查项、对比项、结果数据,都优先使用 <table><thead><tbody><tr><th><td>。',
|
||
'可以在表格前用一个 <p> 给出一句结论,但主体必须是 table。',
|
||
'简单步骤也优先改成表格,不要使用普通项目符号列表作为主体。',
|
||
'重点使用 <strong>,路径/接口/字段使用 <code>,多行示例使用 <pre><code>。',
|
||
'不要输出 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 '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>结果</td><td>AI 服务未返回内容。</td></tr></tbody></table>'
|
||
if (/<(p|ul|ol|li|table|thead|tbody|tr|th|td|strong|em|code|pre|h3|h4|svg)[\s>]/i.test(content)) {
|
||
if (/<table[\s>]/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 '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>结果</td><td>AI 服务未返回内容。</td></tr></tbody></table>'
|
||
const rows = lines.map((line, index) => {
|
||
const normalized = line
|
||
.replace(/^#{1,4}\s+/, '')
|
||
.replace(/^[-*]\s+/, '')
|
||
.replace(/^\d+[.)]\s+/, '')
|
||
return '<tr><td>' + (index + 1) + '</td><td>' + escapeHtml(normalized) + '</td></tr>'
|
||
})
|
||
return '<table><thead><tr><th>序号</th><th>内容</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>'
|
||
}
|
||
|
||
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 '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>说明</td><td>' + escapeHtml(text) + '</td></tr></tbody></table>'
|
||
}
|
||
const rows = blocks.map(item => '<tr><td>' + escapeHtml(item.type) + '</td><td>' + item.value + '</td></tr>')
|
||
return '<table><thead><tr><th>类型</th><th>内容</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>'
|
||
}
|
||
|
||
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, '"')
|
||
.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
|
||
})
|
||
}
|