Files
WC-SPC/MES-Manager_View/mcp-server/server.js
2026-05-27 14:39:35 +08:00

694 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs')
const path = require('path')
const http = require('http')
const https = require('https')
const ROOT = path.resolve(__dirname, '..')
const DOCS_DIR = path.join(ROOT, 'docs')
const ENV_FILE = path.join(__dirname, '.env.local')
const KNOWLEDGE_FILE = path.join(DOCS_DIR, 'wc-spc-knowledge-base.json')
function loadLocalEnv() {
if (!fs.existsSync(ENV_FILE)) return
fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/).forEach(line => {
const text = line.trim()
if (!text || text[0] === '#') return
const index = text.indexOf('=')
if (index <= 0) return
const key = text.slice(0, index).trim()
const value = text.slice(index + 1).trim().replace(/^["']|["']$/g, '')
if (key && process.env[key] == null) process.env[key] = value
})
}
loadLocalEnv()
const PORT = Number(process.env.AI_MCP_PORT || 3100)
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ''
const DEEPSEEK_BASE_URL = (process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com').replace(/\/$/, '')
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-v4-pro'
const MES_BACKEND_URL = process.env.MES_BACKEND_URL || 'http://127.0.0.1:10050/submit/MESCommonBase.ashx'
const SPC_BACKEND_URL = (process.env.SPC_BACKEND_URL || 'http://localhost:57966').replace(/\/$/, '')
const ALLOW_BACKEND_CALLS = process.env.AI_MCP_ALLOW_BACKEND_CALLS === 'true'
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch (error) {
return fallback
}
}
function emptyKnowledgeBase() {
return {
generatedAt: null,
modules: [],
apiMappings: [],
sqlTables: [],
qualityModules: [],
spcCharts: [],
queryHints: []
}
}
let knowledgeBase = readJson(KNOWLEDGE_FILE, emptyKnowledgeBase())
function lower(value) {
return String(value || '').toLowerCase()
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)))
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function table(headers, rows) {
const dataRows = Array.isArray(rows) ? rows : []
let html = '<table class="ai-result-table"><thead><tr>'
headers.forEach(header => {
html += '<th>' + escapeHtml(header.label) + '</th>'
})
html += '</tr></thead><tbody>'
if (!dataRows.length) {
html += '<tr><td colspan="' + headers.length + '">无匹配数据</td></tr>'
} else {
dataRows.forEach(row => {
html += '<tr>'
headers.forEach(header => {
const value = row && row[header.key]
html += '<td>' + escapeHtml(Array.isArray(value) ? value.join(', ') : value) + '</td>'
})
html += '</tr>'
})
}
html += '</tbody></table>'
return html
}
function normalizeRows(rows, limit) {
if (!Array.isArray(rows)) return []
return rows.slice(0, limit || 30).map(row => {
if (!row || typeof row !== 'object') return { value: row }
return row
})
}
function htmlForRows(title, rows, limit) {
const normalized = normalizeRows(rows, limit || 30)
const keys = unique(normalized.flatMap(row => Object.keys(row))).slice(0, 12)
if (!keys.length) return '<h4>' + escapeHtml(title) + '</h4>' + table([{ key: 'message', label: '结果' }], [{ message: '无数据' }])
return '<h4>' + escapeHtml(title) + '</h4>' + table(keys.map(key => ({ key, label: key })), normalized)
}
function tokens(query) {
const source = lower(query)
const parts = source
.replace(/[^\w\u4e00-\u9fa5]+/g, ' ')
.split(/\s+/)
.map(item => item.trim())
.filter(item => item && item.length > 1)
const grams = []
parts.forEach(part => {
if (/[\u4e00-\u9fa5]/.test(part) && part.length > 2) {
for (let size = 2; size <= Math.min(4, part.length); size++) {
for (let index = 0; index <= part.length - size; index++) {
grams.push(part.substr(index, size))
}
}
}
})
return unique(parts.concat(grams)).filter(item => ['页面', '接口', '数据', '查询', '功能', '模块'].indexOf(item) === -1)
}
function scoreText(text, query) {
const haystack = lower(text)
const compactHaystack = haystack.replace(/\s+/g, '')
const q = lower(query)
const compactQuery = q.replace(/\s+/g, '')
if (!q) return 1
let score = compactQuery && compactHaystack.indexOf(compactQuery) > -1 ? 50 : 0
tokens(query).forEach(part => {
if (haystack.indexOf(part) > -1) score += part.length
})
return score
}
function pick(rows, query, fields, limit) {
return (rows || [])
.map(row => {
const text = fields.map(field => {
const value = row[field]
return Array.isArray(value) ? value.join(' ') : value
}).join(' ')
return { row, score: scoreText(text, query) }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit || 20)
.map(item => item.row)
}
function routeCandidates(question, limit) {
const query = String(question || '').replace(/打开|跳转|进入|导航|前往|去到|查看|页面|模块/g, ' ')
return (knowledgeBase.modules || [])
.filter(row => row.routePath)
.map(row => {
const text = [row.module, row.title, row.view, row.routePath, row.domains, row.functionNames, row.keywords].join(' ')
let score = scoreText(text, query || question)
const compactQuestion = String(question || '').replace(/\s+/g, '').toLowerCase()
const compactPath = String(row.routePath || '').replace(/[\/_\s]+/g, '').toLowerCase()
const compactTitle = String(row.title || '').replace(/[\/_\s]+/g, '').toLowerCase()
if (compactQuestion.indexOf('spc分析') > -1 && compactPath.indexOf('searchdataspcanalysis') > -1) score += 100
if (compactQuestion.indexOf('spc') > -1 && compactTitle.indexOf('spc') > -1) score += 30
return Object.assign({ score }, row)
})
.filter(row => row.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit || 8)
}
function isNavigationQuestion(question) {
return /(打开|跳转|进入|导航|前往|去到|切换到)/.test(String(question || '')) || /查看.*页面/.test(String(question || ''))
}
function navigationResponse(question) {
if (!isNavigationQuestion(question)) return null
const candidates = routeCandidates(question, 8)
if (!candidates.length) {
return { html: table([{ key: '提示', label: '提示' }], [{ 提示: '未找到匹配页面,请换一个页面名或模块名。' }]), actions: [] }
}
const target = candidates[0]
return {
html: '<h4>页面导航</h4>' + table([
{ key: 'title', label: '页面' },
{ key: 'routePath', label: '路由' },
{ key: 'view', label: '源码文件' }
], [target]),
actions: [{ type: 'navigate', path: target.routePath, title: target.title, view: target.view }]
}
}
function isQualityQuestion(question) {
return /质量|SPC|巡检|条码|Andon|安灯|测量|工位|过程能力|趋势|直方|正态|排列|X-R|XR|X-S|XS/i.test(String(question || ''))
}
function listQualityModules(limit) {
return (knowledgeBase.qualityModules || []).slice(0, limit || 50).map(row => ({
title: row.Title,
routePath: row.RoutePath,
domains: row.Domains,
queryable: row.Queryable,
view: row.View
}))
}
function qualitySearchResponse(question) {
const rows = pick(knowledgeBase.qualityModules, question, ['Title', 'View', 'Domains', 'QueryNames'], 12)
return '<h4>质量管理匹配模块</h4>' + table([
{ key: 'Title', label: '页面' },
{ key: 'RoutePath', label: '路由' },
{ key: 'Domains', label: '业务域' },
{ key: 'Queryable', label: '可查询' }
], rows)
}
function spcChartType(question) {
const text = String(question || '')
const rules = [
{ type: 'xr', re: /X-R|XR|极差|均值.*极差/i },
{ type: 'xs', re: /X-S|XS|标准差|均值.*标准差/i },
{ type: 'pareto', re: /排列|帕累托|pareto/i },
{ type: 'processCapability', re: /过程能力|CPK|CP|能力/i },
{ type: 'normalDistribution', re: /正态|分布|normal/i },
{ type: 'histogram', re: /直方|频数|histogram/i },
{ type: 'sampleTrend', re: /样本趋势|样本/i },
{ type: 'basicTrend', re: /基本趋势|趋势|折线/i }
]
const match = rules.find(rule => rule.re.test(text))
return match ? match.type : ''
}
function parseDateRange(question) {
const text = String(question || '')
const explicit = text.match(/(\d{4}[-/]\d{1,2}[-/]\d{1,2}).{0,8}(\d{4}[-/]\d{1,2}[-/]\d{1,2})/)
if (explicit) return { startTime: explicit[1].replace(/\//g, '-'), endTime: explicit[2].replace(/\//g, '-') }
const today = new Date()
const end = today.toISOString().slice(0, 10)
const daysMatch = text.match(/最近\s*(\d+)\s*天/)
const days = daysMatch ? Math.min(Math.max(Number(daysMatch[1]), 1), 90) : 7
const startDate = new Date(today.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startTime: startDate.toISOString().slice(0, 10), endTime: end }
}
function parseQuotedOrNamed(question, names) {
const text = String(question || '')
const quoted = text.match(/[“"']([^“”"']+)[”"']/)
if (quoted) return quoted[1]
for (let i = 0; i < names.length; i++) {
const name = names[i]
const re = new RegExp(name + '\\s*[:]?\\s*([^,。\\s]+)')
const match = text.match(re)
if (match) return match[1]
}
return ''
}
function parseSpcParams(question) {
const dateRange = parseDateRange(question)
const sampleSize = (String(question).match(/样本容量\s*[:]?\s*(\d+)/) || [])[1] || '5'
const sampleNumber = (String(question).match(/样本(?:个数|数量)\s*[:]?\s*(\d+)/) || [])[1] || '20'
const usl = (String(question).match(/USL|上限|上规格限/i) || {}).index != null ? ((String(question).match(/(?:USL|上限|上规格限)\s*[:]?\s*(-?\d+(?:\.\d+)?)/i) || [])[1] || '') : ''
const lsl = (String(question).match(/LSL|下限|下规格限/i) || {}).index != null ? ((String(question).match(/(?:LSL|下限|下规格限)\s*[:]?\s*(-?\d+(?:\.\d+)?)/i) || [])[1] || '') : ''
return Object.assign({
opName: parseQuotedOrNamed(question, ['工位', '工位号', '工位名称']),
model: parseQuotedOrNamed(question, ['机型', '型号', '零件号']),
measureName: parseQuotedOrNamed(question, ['测量位置', '位置']),
measureContent: parseQuotedOrNamed(question, ['测量项目', '测量内容', '项目']),
sampleSize,
sampleNumber,
usl,
lsl,
pageSize: 20,
pageCurrent: 1
}, dateRange)
}
function missingSpcParams(params) {
const missing = []
if (!params.opName) missing.push('工位')
if (!params.measureName) missing.push('测量位置')
if (!params.measureContent) missing.push('测量项目')
return missing
}
function buildUrl(endpoint, type, params) {
const query = {
type,
OPNameCheck: params.opName ? 'true' : 'false',
OPName: params.opName || '',
shaftNameCheck: params.measureName ? 'true' : 'false',
shaftName: params.measureName || '',
EngineTypeIDCheck: params.model ? 'true' : 'false',
EngineTypeID: params.model || '',
itemNameCheck: params.measureContent ? 'true' : 'false',
itemName: params.measureContent || '',
startTime: params.startTime,
endTime: params.endTime,
Yangben: params.sampleSize || '5',
Num: params.sampleNumber || '20'
}
if (params.usl) query.Usl = params.usl
if (params.lsl) query.Lsl = params.lsl
if (type === 'GetDataTableByConditent') {
query.page = params.pageCurrent || 1
query.rows = params.pageSize || 20
}
const qs = Object.keys(query).map(key => encodeURIComponent(key) + '=' + encodeURIComponent(query[key])).join('&')
return SPC_BACKEND_URL + '/submit/' + endpoint + '?' + qs
}
function getJson(url) {
return new Promise((resolve, reject) => {
const target = new URL(url)
const client = target.protocol === 'https:' ? https : http
const req = client.request({
method: 'POST',
hostname: target.hostname,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
path: target.pathname + target.search
}, res => {
let text = ''
res.setEncoding('utf8')
res.on('data', chunk => { text += chunk })
res.on('end', () => {
try {
resolve(JSON.parse(text))
} catch (error) {
resolve(text)
}
})
})
req.on('error', reject)
req.end()
})
}
function numericValuesFromRows(rows) {
return normalizeRows(rows, 1000).map(row => {
const keys = Object.keys(row)
const key = keys.find(item => /测量值|value|Value|数值/i.test(item)) || keys.find(item => !Number.isNaN(Number(row[item])))
return key ? Number(row[key]) : NaN
}).filter(value => !Number.isNaN(value))
}
function summaryForValues(values) {
if (!values.length) return []
const sum = values.reduce((acc, item) => acc + item, 0)
const avg = sum / values.length
const variance = values.reduce((acc, item) => acc + Math.pow(item - avg, 2), 0) / values.length
return [
{ name: '样本数', value: values.length },
{ name: '最小值', value: Math.min.apply(null, values).toFixed(4) },
{ name: '最大值', value: Math.max.apply(null, values).toFixed(4) },
{ name: '平均值', value: avg.toFixed(4) },
{ name: '标准差', value: Math.sqrt(variance).toFixed(4) }
]
}
function simpleLineChart(title, values) {
return {
id: 'chart_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
title,
option: {
title: { text: title, left: 'center' },
tooltip: { trigger: 'axis' },
grid: { left: 45, right: 20, top: 55, bottom: 35 },
xAxis: { type: 'category', data: values.map((_, index) => String(index + 1)) },
yAxis: { type: 'value', scale: true },
series: [{ name: '测量值', type: 'line', smooth: true, data: values }]
}
}
}
function simpleBarChart(title, values) {
const bins = {}
values.forEach(value => {
const key = value.toFixed(2)
bins[key] = (bins[key] || 0) + 1
})
const keys = Object.keys(bins).sort((a, b) => Number(a) - Number(b))
return {
id: 'chart_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
title,
option: {
title: { text: title, left: 'center' },
tooltip: { trigger: 'axis' },
grid: { left: 45, right: 20, top: 55, bottom: 45 },
xAxis: { type: 'category', data: keys },
yAxis: { type: 'value' },
series: [{ name: '频数', type: 'bar', data: keys.map(key => bins[key]) }]
}
}
}
async function querySpcChart(args) {
const chartType = args.chartType || spcChartType(args.query || '') || 'basicTrend'
const params = Object.assign(parseSpcParams(args.query || ''), args)
const missing = missingSpcParams(params)
if (missing.length) {
return {
html: '<h4>SPC 查询条件不足</h4>' + table([
{ key: 'field', label: '缺少条件' },
{ key: 'example', label: '示例' }
], missing.map(field => ({ field, example: field + ':请在问题中明确指定' }))),
charts: [],
data: { missing, params }
}
}
const chartMap = {
basicTrend: { endpoint: 'QualityData_TrendPictureBasic.ashx', type: 'GetBaseTrendData', title: '基本趋势图' },
sampleTrend: { endpoint: 'QualityData_TrendPicture.ashx', type: 'GetBaseTrendData', title: '样本趋势图' },
histogram: { endpoint: 'QualityData_Histogram.ashx', type: 'GetDataTableByConditent', title: '直方图' },
normalDistribution: { endpoint: 'QualityData_NormalDistribution.ashx', type: 'GetBaseTrendData', title: '正态分布图' },
processCapability: { endpoint: 'QualityData_NormalDistribution.ashx', type: 'GetBaseTrendData', title: '过程能力分析' },
pareto: { endpoint: 'QualityData_Pareto.ashx', type: 'GetChart', title: '排列图' },
xr: { endpoint: 'QualityData_XR.ashx', type: 'GetBaseTrendData', title: 'X-R 控制图' },
xs: { endpoint: 'QualityData_XS.ashx', type: 'GetBaseTrendData', title: 'X-S 控制图' }
}
const meta = chartMap[chartType] || chartMap.basicTrend
const url = buildUrl(meta.endpoint, meta.type, params)
let raw
try {
raw = await getJson(url)
} catch (error) {
return {
html: '<h4>SPC 接口调用失败</h4>' + table([
{ key: 'name', label: '项目' },
{ key: 'value', label: '内容' }
], [
{ name: '错误', value: error.message },
{ name: '接口', value: url }
]),
charts: [],
data: { error: error.message, params, url }
}
}
const rows = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.rows) ? raw.rows : [])
const values = numericValuesFromRows(rows)
const charts = values.length ? [
/histogram|normalDistribution|processCapability|pareto/.test(chartType) ? simpleBarChart(meta.title, values) : simpleLineChart(meta.title, values)
] : []
const conditionRows = [
{ name: '图表类型', value: meta.title },
{ name: '开始时间', value: params.startTime },
{ name: '结束时间', value: params.endTime },
{ name: '工位', value: params.opName },
{ name: '测量位置', value: params.measureName },
{ name: '测量项目', value: params.measureContent },
{ name: '样本容量', value: params.sampleSize },
{ name: '样本个数', value: params.sampleNumber }
]
const summaryRows = summaryForValues(values)
const sourceRows = [{ endpoint: meta.endpoint, type: meta.type, url }]
let html = '<h4>SPC 查询条件</h4>' + table([{ key: 'name', label: '条件' }, { key: 'value', label: '值' }], conditionRows)
html += '<h4>SPC 统计摘要</h4>' + table([{ key: 'name', label: '指标' }, { key: 'value', label: '值' }], summaryRows)
html += htmlForRows('明细数据', rows, 20)
html += '<h4>数据来源</h4>' + table([{ key: 'endpoint', label: '接口' }, { key: 'type', label: '类型' }, { key: 'url', label: '调用地址' }], sourceRows)
if (!rows.length && typeof raw === 'string') {
html += '<h4>原始返回</h4><pre>' + escapeHtml(raw.slice(0, 1000)) + '</pre>'
}
return { html, charts, data: { params, raw, rows, values, url } }
}
function postJson(url, payload, headers) {
return new Promise((resolve, reject) => {
const target = new URL(url)
const client = target.protocol === 'https:' ? https : http
const body = typeof payload === 'string' ? payload : JSON.stringify(payload)
const req = client.request({
method: 'POST',
hostname: target.hostname,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
path: target.pathname + target.search,
headers: Object.assign({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, headers || {})
}, res => {
let text = ''
res.setEncoding('utf8')
res.on('data', chunk => { text += chunk })
res.on('end', () => {
try {
resolve(JSON.parse(text))
} catch (error) {
resolve({ raw: text, statusCode: res.statusCode })
}
})
})
req.on('error', reject)
req.write(body)
req.end()
})
}
async function askDeepSeek(question, localHtml) {
if (!DEEPSEEK_API_KEY) return localHtml
const context = {
matchedModules: routeCandidates(question, 8),
qualityModules: pick(knowledgeBase.qualityModules, question, ['Title', 'View', 'Domains', 'QueryNames'], 8),
spcCharts: pick(knowledgeBase.spcCharts, question, ['chartType', 'title', 'apiFile', 'endpoint', 'functions'], 8)
}
const system = [
'你是 WC-SPC MES-Manager_View 网页内置的 DeepSeekV4Pro AI 助手。',
'使用中文回答,优先输出 HTML 表格。',
'只能依据项目知识库和工具结果回答,不要编造数据。',
'不要输出可执行 JavaScript图表由系统结构化渲染。'
].join('\n')
const response = await postJson(DEEPSEEK_BASE_URL + '/chat/completions', {
model: DEEPSEEK_MODEL,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: '项目知识库匹配上下文:\n' + JSON.stringify(context, null, 2) },
{ role: 'user', content: question }
],
temperature: 0.2
}, { Authorization: 'Bearer ' + DEEPSEEK_API_KEY })
return response && response.choices && response.choices[0] && response.choices[0].message
? response.choices[0].message.content
: localHtml
}
function localAnswer(question) {
if (/项目|功能|模块|有哪些/.test(question)) {
return '<h4>项目功能概览</h4>' + table([
{ key: 'name', label: '项目' },
{ key: 'value', label: '数量' }
], [
{ name: '页面模块', value: knowledgeBase.modules.length },
{ name: 'API 映射', value: knowledgeBase.apiMappings.length },
{ name: '质量相关模块', value: knowledgeBase.qualityModules.length },
{ name: 'SPC 图表类型', value: knowledgeBase.spcCharts.length }
]) + '<h4>质量相关模块</h4>' + table([
{ key: 'title', label: '页面' },
{ key: 'routePath', label: '路由' },
{ key: 'domains', label: '业务域' }
], listQualityModules(12))
}
if (isQualityQuestion(question)) return qualitySearchResponse(question)
const rows = routeCandidates(question, 12)
return '<h4>项目知识库匹配结果</h4>' + table([
{ key: 'title', label: '页面' },
{ key: 'routePath', label: '路由' },
{ key: 'view', label: '源码文件' },
{ key: 'callCount', label: '调用数' }
], rows)
}
const tools = [
{ name: 'project_summary', description: '返回 WC-SPC 项目结构、知识库、质量模块和 SPC 图表概览。', inputSchema: { type: 'object', properties: {} } },
{ name: 'search_project_functions', description: '按关键字搜索项目页面、接口和功能。', inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' }}, required: ['query'] } },
{ name: 'list_quality_modules', description: '列出质量管理相关页面。', inputSchema: { type: 'object', properties: { limit: { type: 'number' } } } },
{ name: 'search_quality_functions', description: '搜索质量管理相关页面和查询入口。', inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' }}, required: ['query'] } },
{ name: 'list_spc_charts', description: '列出 SPC 图表类型与对应接口。', inputSchema: { type: 'object', properties: {} } },
{ name: 'spc_query_chart', description: '根据条件调用 SPC 接口并返回图表、摘要和明细。', inputSchema: { type: 'object', properties: { query: { type: 'string' }, chartType: { type: 'string' }, opName: { type: 'string' }, measureName: { type: 'string' }, measureContent: { type: 'string' }, startTime: { type: 'string' }, endTime: { type: 'string' } } } },
{ name: 'reload_project_index', description: '重新加载知识库。', inputSchema: { type: 'object', properties: {} } },
{ name: 'call_mes_backend', description: '调用 MESCommonBase.ashx默认禁用。', inputSchema: { type: 'object', properties: { payload: { type: 'object' }}, required: ['payload'] } }
]
async function toolResult(name, args) {
const limit = Math.min(Number(args.limit || 30), 100)
if (name === 'project_summary') {
const rows = [
{ name: '知识库生成时间', value: knowledgeBase.generatedAt || '未生成' },
{ name: '页面模块', value: knowledgeBase.modules.length },
{ name: 'API 映射', value: knowledgeBase.apiMappings.length },
{ name: '质量相关模块', value: knowledgeBase.qualityModules.length },
{ name: 'SPC 图表类型', value: knowledgeBase.spcCharts.length },
{ name: 'DeepSeek 已配置', value: Boolean(DEEPSEEK_API_KEY) },
{ name: 'SPC 后端地址', value: SPC_BACKEND_URL }
]
return { text: JSON.stringify(rows, null, 2), html: '<h4>项目概览</h4>' + table([{ key: 'name', label: '项目' }, { key: 'value', label: '值' }], rows), data: rows }
}
if (name === 'search_project_functions') {
const rows = routeCandidates(args.query, limit)
return { text: JSON.stringify(rows, null, 2), html: table([{ key: 'title', label: '页面' }, { key: 'routePath', label: '路由' }, { key: 'view', label: '源码' }, { key: 'callCount', label: '调用数' }], rows), data: rows }
}
if (name === 'list_quality_modules') {
const rows = listQualityModules(limit)
return { text: JSON.stringify(rows, null, 2), html: table([{ key: 'title', label: '页面' }, { key: 'routePath', label: '路由' }, { key: 'domains', label: '业务域' }, { key: 'queryable', label: '可查询' }], rows), data: rows }
}
if (name === 'search_quality_functions') {
const rows = pick(knowledgeBase.qualityModules, args.query, ['Title', 'View', 'Domains', 'QueryNames'], limit)
return { text: JSON.stringify(rows, null, 2), html: table([{ key: 'Title', label: '页面' }, { key: 'RoutePath', label: '路由' }, { key: 'Domains', label: '业务域' }, { key: 'QueryNames', label: '查询入口' }], rows), data: rows }
}
if (name === 'list_spc_charts') {
const rows = knowledgeBase.spcCharts || []
return { text: JSON.stringify(rows, null, 2), html: table([{ key: 'title', label: '图表' }, { key: 'chartType', label: '类型' }, { key: 'apiFile', label: 'API 文件' }, { key: 'endpoint', label: '后端接口' }], rows), data: rows }
}
if (name === 'spc_query_chart') {
const result = await querySpcChart(args || {})
return { text: JSON.stringify(result.data, null, 2), html: result.html, data: result }
}
if (name === 'reload_project_index') {
knowledgeBase = readJson(KNOWLEDGE_FILE, emptyKnowledgeBase())
return { text: 'reloaded', html: table([{ key: 'name', label: '项目' }, { key: 'value', label: '值' }], [{ name: '状态', value: '知识库已重新加载' }]), data: { ok: true } }
}
if (name === 'call_mes_backend') {
if (!ALLOW_BACKEND_CALLS) {
return { text: 'disabled', html: table([{ key: 'name', label: '项目' }, { key: 'value', label: '值' }], [{ name: '状态', value: '已禁用,避免 AI 直接修改业务数据' }]), data: { disabled: true } }
}
const response = await postJson(MES_BACKEND_URL, args.payload || {})
return { text: JSON.stringify(response, null, 2), html: htmlForRows('MES 后端返回', Array.isArray(response) ? response : [response], 30), data: response }
}
throw new Error('Unknown tool: ' + name)
}
async function handleMcp(body) {
const id = body.id == null ? null : body.id
try {
if (body.method === 'initialize') {
return { jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', serverInfo: { name: 'wc-spc-mcp', version: '1.0.0' }, capabilities: { tools: {} } } }
}
if (body.method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools } }
if (body.method === 'tools/call') {
const result = await toolResult(body.params && body.params.name, (body.params && body.params.arguments) || {})
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: result.text }, { type: 'text', text: result.html }], structuredContent: result.data } }
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }
} catch (error) {
return { jsonrpc: '2.0', id, error: { code: -32000, message: error.message } }
}
}
function parseBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => { chunks.push(Buffer.from(chunk)) })
req.on('end', () => {
try {
const text = Buffer.concat(chunks).toString('utf8')
resolve(text ? JSON.parse(text) : {})
} catch (error) {
reject(error)
}
})
req.on('error', reject)
})
}
function send(res, status, payload) {
const body = typeof payload === 'string' ? payload : JSON.stringify(payload)
res.writeHead(status, {
'Content-Type': typeof payload === 'string' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
})
res.end(body)
}
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') return send(res, 204, '')
try {
if (req.method === 'GET' && req.url === '/health') {
return send(res, 200, {
ok: true,
model: DEEPSEEK_MODEL,
apiKeyConfigured: Boolean(DEEPSEEK_API_KEY),
knowledgeBase: { loaded: Boolean(knowledgeBase.generatedAt), modules: knowledgeBase.modules.length, qualityModules: knowledgeBase.qualityModules.length, spcCharts: knowledgeBase.spcCharts.length },
endpoints: { mes: MES_BACKEND_URL, spc: SPC_BACKEND_URL }
})
}
if (req.method === 'GET' && req.url === '/api/tools') return send(res, 200, { tools })
if (req.method === 'POST' && req.url === '/mcp') return send(res, 200, await handleMcp(await parseBody(req)))
if (req.method === 'POST' && req.url === '/api/assistant/chat') {
const body = await parseBody(req)
const question = String(body.question || '').trim()
if (!question) return send(res, 400, { error: 'question is required' })
const navigation = navigationResponse(question)
if (navigation) return send(res, 200, navigation)
if (/图|趋势|直方|正态|过程能力|排列|X-R|XR|X-S|XS/i.test(question)) {
return send(res, 200, await querySpcChart({ query: question }))
}
const localHtml = localAnswer(question)
const html = await askDeepSeek(question, localHtml)
return send(res, 200, { html })
}
return send(res, 404, { error: 'Not found' })
} catch (error) {
return send(res, 500, { error: error.message })
}
})
server.listen(PORT, () => {
console.log('WC-SPC AI MCP server listening on http://127.0.0.1:' + PORT)
console.log('DeepSeek model: ' + DEEPSEEK_MODEL + ', API key configured: ' + Boolean(DEEPSEEK_API_KEY))
console.log('Knowledge base loaded: ' + Boolean(knowledgeBase.generatedAt))
})