Add DeepSeek AI MCP assistant
This commit is contained in:
52
MES-Manager_View/mcp-server/README.md
Normal file
52
MES-Manager_View/mcp-server/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# WC-SPC AI MCP Service
|
||||
|
||||
本服务为 `MES-Manager_View` 提供本地 MCP 工具和 DeepSeekV4Pro 网页助手代理。
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
npm run kb:build
|
||||
npm run ai:mcp
|
||||
```
|
||||
|
||||
或日常启动:
|
||||
|
||||
```powershell
|
||||
npm run start:daily
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
可创建本地文件 `mcp-server/.env.local`,不要提交到 Git:
|
||||
|
||||
```text
|
||||
AI_MCP_PORT=3100
|
||||
DEEPSEEK_API_KEY=your_deepseek_key
|
||||
DEEPSEEK_MODEL=deepseek-v4-pro
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
MES_BACKEND_URL=http://127.0.0.1:10050/submit/MESCommonBase.ashx
|
||||
SPC_BACKEND_URL=http://localhost:57966
|
||||
AI_MCP_ALLOW_BACKEND_CALLS=false
|
||||
```
|
||||
|
||||
未配置 `DEEPSEEK_API_KEY` 时,助手仍可基于本地知识库回答项目功能、质量模块和 SPC 页面映射问题。
|
||||
|
||||
## 端点
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/tools`
|
||||
- `POST /mcp`
|
||||
- `POST /api/assistant/chat`
|
||||
|
||||
## 工具
|
||||
|
||||
- `project_summary`
|
||||
- `search_project_functions`
|
||||
- `list_quality_modules`
|
||||
- `search_quality_functions`
|
||||
- `list_spc_charts`
|
||||
- `spc_query_chart`
|
||||
- `reload_project_index`
|
||||
- `call_mes_backend`
|
||||
|
||||
`call_mes_backend` 默认禁用,避免 AI 直接执行会修改业务数据的后端操作。
|
||||
486
MES-Manager_View/mcp-server/build-knowledge-base.js
Normal file
486
MES-Manager_View/mcp-server/build-knowledge-base.js
Normal file
@@ -0,0 +1,486 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const DOCS_DIR = path.join(ROOT, 'docs')
|
||||
const OUT_FILE = path.join(DOCS_DIR, 'wc-spc-knowledge-base.json')
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
function walk(dir, predicate, result) {
|
||||
result = result || []
|
||||
if (!fs.existsSync(dir)) return result
|
||||
fs.readdirSync(dir, { withFileTypes: true }).forEach(entry => {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist') return
|
||||
walk(fullPath, predicate, result)
|
||||
} else if (!predicate || predicate(fullPath)) {
|
||||
result.push(fullPath)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function rel(file) {
|
||||
return path.relative(ROOT, file).replace(/\//g, '\\')
|
||||
}
|
||||
|
||||
function lineNumber(text, index) {
|
||||
return text.slice(0, index).split(/\r?\n/).length
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value == null ? '' : value)
|
||||
return '"' + text.replace(/"/g, '""') + '"'
|
||||
}
|
||||
|
||||
function writeCsv(file, headers, rows) {
|
||||
const body = [headers.map(csvEscape).join(',')]
|
||||
rows.forEach(row => {
|
||||
body.push(headers.map(header => csvEscape(row[header])).join(','))
|
||||
})
|
||||
fs.writeFileSync(path.join(DOCS_DIR, file), body.join('\n'), 'utf8')
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeSlash(value) {
|
||||
return String(value || '').replace(/\//g, '\\')
|
||||
}
|
||||
|
||||
function moduleFromView(view) {
|
||||
const match = normalizeSlash(view).match(/^src\\views\\([^\\]+)/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
function viewToRoutePath(view) {
|
||||
const normalized = normalizeSlash(view)
|
||||
const match = normalized.match(/^src\\views\\(.+)\\index\.vue$/i)
|
||||
if (!match) return ''
|
||||
return '/' + match[1].replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function displayTitleFromView(view) {
|
||||
const normalized = normalizeSlash(view)
|
||||
const match = normalized.match(/^src\\views\\(.+?)\\index\.vue$/i)
|
||||
if (!match) return normalized.replace(/^src\\views\\/, '').replace(/\\/g, ' / ')
|
||||
return match[1].replace(/\\/g, ' / ')
|
||||
}
|
||||
|
||||
function keywordsFromText(text) {
|
||||
const raw = String(text || '')
|
||||
.replace(/[^\w\u4e00-\u9fa5]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(item => item.trim())
|
||||
.filter(item => item && item.length > 1)
|
||||
const grams = []
|
||||
raw.forEach(item => {
|
||||
if (/[\u4e00-\u9fa5]/.test(item) && item.length > 2) {
|
||||
for (let size = 2; size <= Math.min(4, item.length); size++) {
|
||||
for (let index = 0; index <= item.length - size; index++) {
|
||||
grams.push(item.substr(index, size))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return unique(raw.concat(grams)).slice(0, 120)
|
||||
}
|
||||
|
||||
function inferAction(name) {
|
||||
const text = String(name || '')
|
||||
if (/删除|delete/i.test(text)) return '删除'
|
||||
if (/修改|编辑|更新|update/i.test(text)) return '修改'
|
||||
if (/增加|新增|添加|insert|add/i.test(text)) return '增加'
|
||||
if (/导入|上传|upload/i.test(text)) return '导入'
|
||||
if (/导出|下载|download|excel/i.test(text)) return '导出'
|
||||
if (/select|查询|获取|列表|综合|分页|统计|明细|检索/i.test(text)) return '查询'
|
||||
return ''
|
||||
}
|
||||
|
||||
function isQueryAction(action, type, name) {
|
||||
if (/删除|修改|增加|导入|上传|提交|审核|执行|启用|禁用/.test(action)) return false
|
||||
if (/^\s*select\b/i.test(String(name || ''))) return true
|
||||
return action === '查询' || type === '11' || type === '2001' || type === '3'
|
||||
}
|
||||
|
||||
function inferDomain(name, fallback) {
|
||||
const text = String(name || '')
|
||||
if (text.indexOf('_') > -1) return text.split('_')[0]
|
||||
if (text.indexOf('SPC') > -1) return 'SPC分析'
|
||||
if (text.indexOf('质量') > -1) return '质量管理'
|
||||
return fallback || ''
|
||||
}
|
||||
|
||||
function extractTables(sql) {
|
||||
const tables = []
|
||||
const re = /\b(?:from|join)\s+((?:\[?[A-Za-z0-9_\u4e00-\u9fa5]+\]?\.)?\[?[A-Za-z0-9_\u4e00-\u9fa5]+\]?)/gi
|
||||
let match
|
||||
while ((match = re.exec(String(sql || '')))) {
|
||||
tables.push(match[1].replace(/\[/g, '').replace(/\]/g, ''))
|
||||
}
|
||||
return unique(tables)
|
||||
}
|
||||
|
||||
function inferObjectFromName(name) {
|
||||
const value = String(name || '').trim()
|
||||
if (!value || /^\s*select\b/i.test(value)) return ''
|
||||
return value
|
||||
.replace(/_查询数据.*$/, '')
|
||||
.replace(/_查询.*$/, '')
|
||||
.replace(/_分页.*$/, '')
|
||||
.replace(/_列表.*$/, '')
|
||||
.replace(/_综合.*$/, '')
|
||||
.replace(/_统计.*$/, '')
|
||||
.replace(/_增加.*$/, '')
|
||||
.replace(/_修改.*$/, '')
|
||||
.replace(/_删除.*$/, '')
|
||||
}
|
||||
|
||||
function extractApiMappings() {
|
||||
const apiRoots = [
|
||||
path.join(ROOT, 'src', 'api'),
|
||||
path.join(ROOT, 'src', 'assets', 'img', 'api')
|
||||
]
|
||||
const rows = []
|
||||
apiRoots.forEach(root => {
|
||||
walk(root, file => /\.js$/i.test(file)).forEach(file => {
|
||||
const text = readText(file)
|
||||
const relative = rel(file)
|
||||
const endpointMatches = []
|
||||
const endpointRe = /['"`]([^'"`]*\/submit\/([^'"`?]+\.ashx)\?type=([^'"`&]+)[^'"`]*)['"`]/gi
|
||||
let endpointMatch
|
||||
while ((endpointMatch = endpointRe.exec(text))) {
|
||||
endpointMatches.push({
|
||||
line: lineNumber(text, endpointMatch.index),
|
||||
endpoint: endpointMatch[2],
|
||||
type: endpointMatch[3],
|
||||
url: endpointMatch[1]
|
||||
})
|
||||
}
|
||||
const functionRe = /export\s+function\s+([A-Za-z0-9_$\u4e00-\u9fa5]+)\s*\(([^)]*)\)/g
|
||||
let fnMatch
|
||||
while ((fnMatch = functionRe.exec(text))) {
|
||||
const line = lineNumber(text, fnMatch.index)
|
||||
const nearest = endpointMatches
|
||||
.map(item => Object.assign({ distance: Math.abs(item.line - line) }, item))
|
||||
.sort((a, b) => a.distance - b.distance)[0]
|
||||
const action = inferAction((nearest && nearest.type) || fnMatch[1])
|
||||
rows.push({
|
||||
ApiFile: relative,
|
||||
Line: line,
|
||||
FunctionName: fnMatch[1],
|
||||
Params: fnMatch[2].replace(/\s+/g, ' '),
|
||||
Endpoint: nearest ? nearest.endpoint : '',
|
||||
Type: nearest ? nearest.type : '',
|
||||
Action: action,
|
||||
Domain: inferDomain((nearest && nearest.endpoint) || relative, moduleFromView(relative)),
|
||||
ApiNameOrSql: nearest ? nearest.type : fnMatch[1],
|
||||
InferredObject: inferObjectFromName((nearest && nearest.type) || fnMatch[1]),
|
||||
ParamExpression: fnMatch[2].replace(/\s+/g, ' ')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function extractViewCalls() {
|
||||
const rows = []
|
||||
const imports = []
|
||||
walk(path.join(ROOT, 'src', 'views'), file => /\.vue$/i.test(file)).forEach(file => {
|
||||
const text = readText(file)
|
||||
const relative = rel(file)
|
||||
const moduleName = moduleFromView(relative)
|
||||
const importRe = /from\s+['"]@\/(api\/[^'"]+)['"]/g
|
||||
let importMatch
|
||||
while ((importMatch = importRe.exec(text))) {
|
||||
imports.push({
|
||||
View: relative,
|
||||
ApiFile: ('src\\' + importMatch[1]).replace(/\//g, '\\'),
|
||||
Line: lineNumber(text, importMatch.index)
|
||||
})
|
||||
}
|
||||
const createDataRe = /CreateData\s*\(\s*['"]([^'"]*)['"]\s*,\s*(['"`])([\s\S]*?)\2/g
|
||||
let match
|
||||
while ((match = createDataRe.exec(text))) {
|
||||
const name = match[3].replace(/\s+/g, ' ').trim()
|
||||
const action = inferAction(name)
|
||||
rows.push({
|
||||
View: relative,
|
||||
ViewModule: moduleName,
|
||||
RoutePath: viewToRoutePath(relative),
|
||||
Line: lineNumber(text, match.index),
|
||||
Source: 'CreateData',
|
||||
Type: match[1],
|
||||
Action: action,
|
||||
Domain: inferDomain(name, moduleName),
|
||||
NameOrSql: name,
|
||||
Queryable: isQueryAction(action, match[1], name)
|
||||
})
|
||||
}
|
||||
const rawSqlRe = /(['"`])\s*(select\s+[\s\S]{10,}?)\1/gi
|
||||
while ((match = rawSqlRe.exec(text))) {
|
||||
const sql = match[2].replace(/\s+/g, ' ').trim()
|
||||
if (!/\bfrom\b/i.test(sql)) continue
|
||||
rows.push({
|
||||
View: relative,
|
||||
ViewModule: moduleName,
|
||||
RoutePath: viewToRoutePath(relative),
|
||||
Line: lineNumber(text, match.index),
|
||||
Source: 'RawSQL',
|
||||
Type: '3',
|
||||
Action: '查询',
|
||||
Domain: 'RawSQL',
|
||||
NameOrSql: sql,
|
||||
Queryable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
return { rows, imports }
|
||||
}
|
||||
|
||||
function buildSpcMappings(apiMappings, viewCalls) {
|
||||
const known = [
|
||||
{ chartType: 'basicTrend', title: '基本趋势图', apiFile: 'src\\api\\SPCanalysis\\basicTrendTap.js', endpoint: 'QualityData_TrendPictureBasic.ashx' },
|
||||
{ chartType: 'sampleTrend', title: '样本趋势图', apiFile: 'src\\api\\SPCanalysis\\sampleTrendChart.js', endpoint: 'QualityData_TrendPicture.ashx' },
|
||||
{ chartType: 'histogram', title: '直方图', apiFile: 'src\\api\\SPCanalysis\\histogram.js', endpoint: 'QualityData_Histogram.ashx' },
|
||||
{ chartType: 'normalDistribution', title: '正态分布图', apiFile: 'src\\api\\SPCanalysis\\normal_distribution.js', endpoint: 'QualityData_NormalDistribution.ashx' },
|
||||
{ chartType: 'processCapability', title: '过程能力分析', apiFile: 'src\\api\\SPCanalysis\\processCapabilityAnalysis.js', endpoint: 'QualityData_NormalDistribution.ashx' },
|
||||
{ chartType: 'pareto', title: '排列图', apiFile: 'src\\api\\SPCanalysis\\arrangeChart.js', endpoint: 'QualityData_Pareto.ashx' },
|
||||
{ chartType: 'xr', title: 'X-R 控制图', apiFile: 'src\\api\\SPCanalysis\\QualityData_XR.js', endpoint: 'QualityData_XR.ashx' },
|
||||
{ chartType: 'xs', title: 'X-S 控制图', apiFile: 'src\\api\\SPCanalysis\\QualityData_XS.js', endpoint: 'QualityData_XS.ashx' }
|
||||
]
|
||||
return known.map(item => {
|
||||
const apiRows = apiMappings.filter(row => normalizeSlash(row.ApiFile) === item.apiFile)
|
||||
const views = unique(viewCalls.filter(row => normalizeSlash(row.View).indexOf('SearchData') > -1 && row.NameOrSql.indexOf('SPC') > -1).map(row => row.View))
|
||||
return Object.assign({}, item, {
|
||||
functions: unique(apiRows.map(row => row.FunctionName)).join('|'),
|
||||
types: unique(apiRows.map(row => row.Type)).join('|'),
|
||||
views: views.join('|')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildQualityModules(viewCalls) {
|
||||
const map = {}
|
||||
viewCalls.forEach(row => {
|
||||
const text = [row.View, row.Domain, row.NameOrSql].join(' ')
|
||||
const quality = /Quality|质量|SPC|Andon|巡检|条码|LineQuality|QualityData/i.test(text)
|
||||
if (!quality) return
|
||||
if (!map[row.View]) {
|
||||
map[row.View] = {
|
||||
View: row.View,
|
||||
Module: row.ViewModule,
|
||||
RoutePath: row.RoutePath,
|
||||
Title: displayTitleFromView(row.View),
|
||||
Domains: [],
|
||||
QueryNames: [],
|
||||
Queryable: false
|
||||
}
|
||||
}
|
||||
map[row.View].Domains.push(row.Domain)
|
||||
if (row.Queryable) {
|
||||
map[row.View].Queryable = true
|
||||
map[row.View].QueryNames.push(row.NameOrSql)
|
||||
}
|
||||
})
|
||||
return Object.keys(map).sort().map(key => {
|
||||
const item = map[key]
|
||||
item.Domains = unique(item.Domains).join('|')
|
||||
item.QueryNames = unique(item.QueryNames).join('|')
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureDir(DOCS_DIR)
|
||||
const apiMappings = extractApiMappings()
|
||||
const viewData = extractViewCalls()
|
||||
const viewCalls = viewData.rows
|
||||
const viewImports = viewData.imports
|
||||
const summaryMap = {}
|
||||
viewCalls.forEach(row => {
|
||||
if (!summaryMap[row.View]) {
|
||||
summaryMap[row.View] = {
|
||||
View: row.View,
|
||||
ViewModule: row.ViewModule,
|
||||
RoutePath: row.RoutePath,
|
||||
Count: 0,
|
||||
Domains: [],
|
||||
Names: []
|
||||
}
|
||||
}
|
||||
summaryMap[row.View].Count++
|
||||
summaryMap[row.View].Domains.push(row.Domain)
|
||||
summaryMap[row.View].Names.push(row.NameOrSql)
|
||||
})
|
||||
const viewSummary = Object.keys(summaryMap).sort().map(key => {
|
||||
const item = summaryMap[key]
|
||||
item.Domains = unique(item.Domains).join('|')
|
||||
item.Names = unique(item.Names).join('|')
|
||||
return item
|
||||
})
|
||||
const rawSqlRows = []
|
||||
viewCalls.filter(row => row.Source === 'RawSQL').forEach(row => {
|
||||
rawSqlRows.push({
|
||||
ApiFile: row.View,
|
||||
Line: row.Line,
|
||||
Tables: extractTables(row.NameOrSql).join('|'),
|
||||
Sql: row.NameOrSql
|
||||
})
|
||||
})
|
||||
const spcMappings = buildSpcMappings(apiMappings, viewCalls)
|
||||
const qualityModules = buildQualityModules(viewCalls)
|
||||
const modules = viewSummary.map(row => {
|
||||
const calls = viewCalls.filter(call => call.View === row.View)
|
||||
const primaryQueries = calls.filter(call => call.Queryable).map(call => ({
|
||||
kind: call.Source === 'RawSQL' ? 'raw-sql' : 'create-data',
|
||||
view: call.View,
|
||||
line: Number(call.Line || 0),
|
||||
source: call.Source,
|
||||
type: call.Type,
|
||||
action: call.Action,
|
||||
domain: call.Domain,
|
||||
nameOrSql: call.NameOrSql,
|
||||
queryTables: call.Source === 'RawSQL' ? extractTables(call.NameOrSql) : [inferObjectFromName(call.NameOrSql)].filter(Boolean),
|
||||
keywords: keywordsFromText([call.View, call.Domain, call.NameOrSql].join(' '))
|
||||
}))
|
||||
return {
|
||||
module: row.ViewModule,
|
||||
view: row.View,
|
||||
title: displayTitleFromView(row.View),
|
||||
routePath: row.RoutePath,
|
||||
callCount: Number(row.Count || calls.length || 0),
|
||||
domains: row.Domains ? row.Domains.split('|') : [],
|
||||
functionNames: row.Names ? row.Names.split('|') : [],
|
||||
calls,
|
||||
primaryQueries,
|
||||
queryable: primaryQueries.length > 0,
|
||||
queryTables: unique(primaryQueries.flatMap(query => query.queryTables || [])),
|
||||
qualityRelated: qualityModules.some(item => item.View === row.View),
|
||||
keywords: keywordsFromText([row.ViewModule, row.View, row.Domains, row.Names].join(' '))
|
||||
}
|
||||
})
|
||||
const sqlTables = []
|
||||
rawSqlRows.forEach(row => {
|
||||
row.Tables.split('|').filter(Boolean).forEach(table => {
|
||||
sqlTables.push({
|
||||
table,
|
||||
sourceFile: row.ApiFile,
|
||||
line: row.Line,
|
||||
sql: row.Sql,
|
||||
source: 'raw-sql',
|
||||
queryable: true,
|
||||
keywords: keywordsFromText([table, row.ApiFile, row.Sql].join(' '))
|
||||
})
|
||||
})
|
||||
})
|
||||
modules.forEach(module => {
|
||||
module.primaryQueries.forEach(query => {
|
||||
;(query.queryTables || []).forEach(table => {
|
||||
if (!table) return
|
||||
sqlTables.push({
|
||||
table,
|
||||
sourceFile: module.view,
|
||||
line: query.line,
|
||||
sql: query.kind === 'raw-sql' ? query.nameOrSql : '',
|
||||
source: query.kind === 'raw-sql' ? 'raw-sql' : 'inferred-object',
|
||||
queryable: true,
|
||||
keywords: keywordsFromText([table, module.view, query.nameOrSql].join(' '))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
const tableMap = {}
|
||||
sqlTables.forEach(item => {
|
||||
if (!tableMap[item.table]) tableMap[item.table] = Object.assign({}, item, { references: [] })
|
||||
tableMap[item.table].references.push({
|
||||
sourceFile: item.sourceFile,
|
||||
line: item.line,
|
||||
source: item.source,
|
||||
sql: item.sql
|
||||
})
|
||||
})
|
||||
const dedupedTables = Object.keys(tableMap).sort().map(key => tableMap[key])
|
||||
const knowledgeBase = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
project: 'WC-SPC MES-Manager_View',
|
||||
modules,
|
||||
apiMappings: apiMappings.map(row => ({
|
||||
apiFile: row.ApiFile,
|
||||
line: Number(row.Line || 0),
|
||||
functionName: row.FunctionName,
|
||||
params: row.Params,
|
||||
endpoint: row.Endpoint,
|
||||
type: row.Type,
|
||||
action: row.Action,
|
||||
domain: row.Domain,
|
||||
apiNameOrSql: row.ApiNameOrSql,
|
||||
inferredObject: row.InferredObject,
|
||||
queryable: isQueryAction(row.Action, row.Type, row.ApiNameOrSql),
|
||||
keywords: keywordsFromText([row.ApiFile, row.FunctionName, row.Endpoint, row.Type].join(' '))
|
||||
})),
|
||||
sqlTables: dedupedTables,
|
||||
qualityModules,
|
||||
spcCharts: spcMappings,
|
||||
queryHints: modules.map(item => ({
|
||||
kind: 'module',
|
||||
key: item.view,
|
||||
title: item.title,
|
||||
keywords: item.keywords,
|
||||
tableCandidates: item.queryTables
|
||||
}))
|
||||
}
|
||||
writeCsv('api-name-mapping.csv', ['ApiFile', 'Line', 'FunctionName', 'Params', 'Endpoint', 'Type', 'Action', 'Domain', 'ApiNameOrSql', 'InferredObject', 'ParamExpression'], apiMappings)
|
||||
writeCsv('view-api-imports.csv', ['View', 'ApiFile', 'Line'], viewImports)
|
||||
writeCsv('view-database-calls.csv', ['View', 'ViewModule', 'RoutePath', 'Line', 'Source', 'Type', 'Action', 'Domain', 'NameOrSql', 'Queryable'], viewCalls)
|
||||
writeCsv('view-database-calls-summary.csv', ['View', 'ViewModule', 'RoutePath', 'Count', 'Domains', 'Names'], viewSummary)
|
||||
writeCsv('raw-sql-table-mapping.csv', ['ApiFile', 'Line', 'Tables', 'Sql'], rawSqlRows)
|
||||
writeCsv('spc-chart-mapping.csv', ['chartType', 'title', 'apiFile', 'endpoint', 'functions', 'types', 'views'], spcMappings)
|
||||
writeCsv('quality-module-mapping.csv', ['View', 'Module', 'RoutePath', 'Title', 'Domains', 'QueryNames', 'Queryable'], qualityModules)
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(knowledgeBase, null, 2), 'utf8')
|
||||
const report = [
|
||||
'# WC-SPC 项目静态分析',
|
||||
'',
|
||||
'生成时间:' + new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
'',
|
||||
'## 结论',
|
||||
'',
|
||||
'- 项目为 Vue 2 + Element UI 前端和 ASP.NET Web Site 后端组合。',
|
||||
'- 前端通用业务接口通过 `src/utils/request.js` 调用 `MESCommonBase.ashx`。',
|
||||
'- SPC 专项接口位于 `src/api/SPCanalysis`,后端对应 `QualityData_*.ashx`。',
|
||||
'- 本文件由 `mcp-server/build-knowledge-base.js` 自动生成,用于 AI MCP 服务。',
|
||||
'',
|
||||
'## 统计',
|
||||
'',
|
||||
'| 项目 | 数量 |',
|
||||
'| --- | ---: |',
|
||||
'| 页面查询调用 | ' + viewCalls.length + ' |',
|
||||
'| 页面汇总 | ' + viewSummary.length + ' |',
|
||||
'| API 映射 | ' + apiMappings.length + ' |',
|
||||
'| Raw SQL | ' + rawSqlRows.length + ' |',
|
||||
'| 质量相关模块 | ' + qualityModules.length + ' |',
|
||||
'| SPC 图表类型 | ' + spcMappings.length + ' |'
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(DOCS_DIR, 'wc-spc-static-analysis.md'), report, 'utf8')
|
||||
console.log('Knowledge base generated: ' + OUT_FILE)
|
||||
console.log('modules=' + modules.length)
|
||||
console.log('apiMappings=' + apiMappings.length)
|
||||
console.log('qualityModules=' + qualityModules.length)
|
||||
console.log('spcCharts=' + spcMappings.length)
|
||||
}
|
||||
|
||||
main()
|
||||
693
MES-Manager_View/mcp-server/server.js
Normal file
693
MES-Manager_View/mcp-server/server.js
Normal file
@@ -0,0 +1,693 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
Reference in New Issue
Block a user