const fs = require('fs'); const path = require('path'); const { PACKAGE_ROOT, resolveWorkspaceRoot } = require('../core/runtime-context'); const PROJECT_ROOT = resolveWorkspaceRoot({ packageRoot: PACKAGE_ROOT }); const PACKAGE_KNOWLEDGE_BASE_DIR = path.join(PACKAGE_ROOT, 'knowledge-base'); const PROJECT_KNOWLEDGE_BASE_DIR = path.join(PROJECT_ROOT, 'knowledge-base'); const KNOWLEDGE_BASE_DIR = fs.existsSync(path.join(PROJECT_KNOWLEDGE_BASE_DIR, 'catalog.json')) ? PROJECT_KNOWLEDGE_BASE_DIR : PACKAGE_KNOWLEDGE_BASE_DIR; const CATALOG_FILE = path.join(KNOWLEDGE_BASE_DIR, 'catalog.json'); const SOURCE_WORKSPACE_ROOT = path.resolve(PACKAGE_ROOT, '..', '..'); const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.csv', '.txt', '.js']); const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; const PAGE_BY_SKILL = { 'qiwei-real-estate-auto-reply': 'agent', 'qiwei-agent-supervisor': 'agent', 'qiwei-group-management': 'groups', 'qiwei-customer-ops': 'customer-ops', 'qiwei-portrait-tags': 'portraits', 'qiwei-customer-transfer': 'transfers', 'qiwei-official-meeting': 'knowledge', 'qiwei-official-doc': 'knowledge', 'qiwei-official-todo': 'knowledge', 'qiwei-login': 'status', 'qiwei-dashboard': 'status', }; const CAPABILITY_TAGS = { 'qiwei-api-catalog': ['100+ API', '接口文档', '通用调用'], 'qiwei-broker-playbook': ['顾问策略', '批量生成', '导出'], 'qiwei-capability-router': ['能力路由', '双通道', '自动选择'], 'qiwei-customer-ops': ['批量加好友', '建群', '欢迎语'], 'qiwei-customer-transfer': ['客户交接', '预览', '执行'], 'qiwei-dashboard': ['本地工作台', '状态管理'], 'qiwei-group-management': ['群识别', '回调接入', '新消息'], 'qiwei-login': ['扫码登录', '订阅', '席位'], 'qiwei-official-doc': ['文档知识库', 'Markdown', '官方 CLI'], 'qiwei-official-meeting': ['会议同步', 'AI 知识沉淀', '官方 CLI'], 'qiwei-official-todo': ['待办同步', '任务推进', '官方 CLI'], 'qiwei-portrait-tags': ['客户画像', '标签', '批量导出'], 'qiwei-real-estate-auto-reply': ['房产 Agent', '需求识别', '房源工具'], 'qiwei-agent-supervisor': ['人工监管', '审核', '审计'], 'qiwei-voice': ['语音下载', '解码', '转写'], 'qiwei-webhook-relay': ['Webhook', 'Relay', '长轮询'], }; function readJson(filePath, fallback = {}) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); } catch { return fallback; } } function packageDefinitions() { const definitions = [ { id: 'unified-source', label: '统一源码版', description: '当前 4320 Dashboard 的主运行包,承载页面、MCP 工具和真实企微 Agent。', root: PACKAGE_ROOT, status: 'running', }, ...(PROJECT_ROOT.toLowerCase() !== PACKAGE_ROOT.toLowerCase() ? [{ id: 'customer-project', label: '当前客户项目', description: '客户项目中的自定义 Skills、知识库和业务扩展。', root: PROJECT_ROOT, skillsRoot: path.join(PROJECT_ROOT, '.claude', 'skills'), status: 'active', }] : []), { id: 'openclaw', label: 'OpenClaw 企微技能包', description: 'OpenClaw 版本的企微技能与独立 Agent 工具实现。', root: path.resolve(SOURCE_WORKSPACE_ROOT, '..', 'openclaw-voc-skill', 'claude-code', 'claude-code-qiwe-assistant'), status: 'connected', }, { id: 'agent-workbench', label: '企微 Agent Workbench', description: 'SQLite 状态机、人工审核、暂停、审计和工具循环的来源实现。', root: path.join(SOURCE_WORKSPACE_ROOT, 'qiwei-agent-workbench'), status: 'merged', }, ]; const seenRoots = new Set(); return definitions.filter(item => { if (!fs.existsSync(item.root)) return false; const root = path.resolve(item.root).toLowerCase(); if (seenRoots.has(root)) return false; seenRoots.add(root); return true; }); } function loadCatalog() { const parsed = readJson(CATALOG_FILE, { version: 1, libraries: [] }); const libraries = (parsed.libraries || []).map(item => ({ ...item, root: path.resolve(KNOWLEDGE_BASE_DIR, item.path), extensions: new Set((item.extensions || []).map(value => String(value).toLowerCase())), kind: item.id === 'property-data' ? 'property-library' : item.id === 'meeting-knowledge' ? 'meeting-library' : item.id === 'doc-knowledge' ? 'doc-library' : item.id === 'todo-knowledge' ? 'todo-library' : item.id === 'task-knowledge' ? 'task-library' : 'document-library', })).filter(item => item.id !== 'property-data' || fs.existsSync(item.root)); for (const pkg of packageDefinitions()) { const skillsRoot = pkg.skillsRoot || path.join(pkg.root, 'skills'); if (!fs.existsSync(skillsRoot)) continue; libraries.push({ id: `skills-${pkg.id}`, label: `${pkg.label} · 技能说明`, description: 'SKILL.md 与 references 文档', root: skillsRoot, extensions: new Set(['.md', '.json', '.txt']), kind: 'skill-library', }); } return { version: parsed.version || 1, libraries }; } function encodeNodeId(libraryId, relativePath) { return `${libraryId}~${Buffer.from(String(relativePath || ''), 'utf8').toString('base64url')}`; } function decodeNodeId(nodeId) { const index = String(nodeId || '').indexOf('~'); if (index < 1) throw new Error('知识库节点不存在'); const libraryId = nodeId.slice(0, index); const relativePath = Buffer.from(nodeId.slice(index + 1), 'base64url').toString('utf8'); return { libraryId, relativePath }; } function safeNodePath(nodeId) { const { libraryId, relativePath } = decodeNodeId(nodeId); const library = loadCatalog().libraries.find(item => item.id === libraryId); if (!library) throw new Error('知识库目录不存在'); const resolved = path.resolve(library.root, relativePath); const rootPrefix = `${path.resolve(library.root).toLowerCase()}${path.sep}`; if (resolved.toLowerCase() !== path.resolve(library.root).toLowerCase() && !resolved.toLowerCase().startsWith(rootPrefix)) { throw new Error('禁止访问知识库目录之外的文件'); } return { library, filePath: resolved, relativePath }; } function fileKind(filePath) { const base = path.basename(filePath).toLowerCase(); const extension = path.extname(filePath).toLowerCase(); if (base === 'properties.json') return 'property-dataset'; if (base === 'skill.md') return 'skill-document'; if (extension === '.md') return 'markdown'; if (extension === '.json') return 'json'; if (extension === '.csv') return 'csv'; if (extension === '.js') return 'code'; return 'text'; } function buildDirectoryChildren(library, directory, relativeDir = '', depth = 0) { if (depth > 7 || !fs.existsSync(directory)) return []; const entries = fs.readdirSync(directory, { withFileTypes: true }) .filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules') .map(entry => { const relativePath = path.join(relativeDir, entry.name); const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { const children = buildDirectoryChildren(library, fullPath, relativePath, depth + 1); if (!children.length) return null; return { id: encodeNodeId(library.id, relativePath), name: entry.name, type: 'folder', children, fileCount: children.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0), }; } const extension = path.extname(entry.name).toLowerCase(); if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) return null; const stats = fs.statSync(fullPath); return { id: encodeNodeId(library.id, relativePath), name: entry.name, type: 'file', kind: fileKind(fullPath), size: stats.size, modifiedAt: stats.mtime.toISOString(), relativePath: relativePath.replace(/\\/g, '/'), }; }) .filter(Boolean); return entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; return a.name.localeCompare(b.name, 'zh-CN'); }); } function propertySourcePath() { const library = loadCatalog().libraries.find(item => item.id === 'property-data'); if (!library) throw new Error('房源数据目录未配置'); const filePath = path.join(library.root, 'properties.json'); if (!fs.existsSync(filePath)) throw new Error('properties.json 不存在'); return filePath; } function normalizeProperty(raw) { return { id: String(raw.id || raw.objectId || raw.property_id || ''), community: raw.community || raw.community_name || '', district: raw.district || raw.area_name || '', totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0), unitPrice: Number(raw.unitPrice ?? raw.unit_price ?? raw.price_unit ?? 0), layout: raw.layout || raw.house_type || '', area: Number(raw.area || 0), floor: raw.floor || raw.floor_info || '', floorLevel: raw.floorLevel || raw.floor_level || '', orientation: raw.orientation || '', decoration: raw.decoration || '', buildingAge: Number(raw.buildingAge ?? raw.building_age ?? 0), isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district), schoolName: raw.schoolName || raw.school_name || '', parking: raw.parking || '', surrounding: raw.surrounding || '', ownerSituation: raw.ownerSituation || raw.owner_situation || '', priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0), isFiveYearOnly: Boolean(raw.isFiveYearOnly ?? raw.is_five_year_only), highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [], scores: { community: Number(raw.communityQuality || 0), transport: Number(raw.transportScore || 0), surrounding: Number(raw.surroundingScore || 0), priceAdvantage: Number(raw.priceAdvantage || 0), }, }; } function allProperties() { const parsed = readJson(propertySourcePath(), {}); const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []); return rows.map(normalizeProperty).filter(item => item.id || item.community); } function propertyStats(properties) { const prices = properties.map(item => item.totalPrice).filter(value => value > 0); return { total: properties.length, districts: [...new Set(properties.map(item => item.district.split('-')[0]).filter(Boolean))].sort(), layouts: [...new Set(properties.map(item => item.layout).filter(Boolean))].sort(), decorations: [...new Set(properties.map(item => item.decoration).filter(Boolean))].sort(), minPrice: prices.length ? Math.min(...prices) : 0, maxPrice: prices.length ? Math.max(...prices) : 0, averagePrice: prices.length ? Math.round(prices.reduce((sum, value) => sum + value, 0) / prices.length) : 0, schoolDistrictCount: properties.filter(item => item.isSchoolDistrict).length, }; } function listKnowledgeTree() { const catalog = loadCatalog(); const roots = catalog.libraries.map(library => { const fileChildren = buildDirectoryChildren(library, library.root); const virtualNode = library.kind === 'meeting-library' ? { relativePath: '__meeting_dashboard__', name: '会议工作台', kind: 'meeting-dashboard' } : library.kind === 'doc-library' ? { relativePath: '__doc_dashboard__', name: '文档工作台', kind: 'doc-dashboard' } : library.kind === 'todo-library' ? { relativePath: '__todo_dashboard__', name: '待办中心', kind: 'todo-dashboard' } : library.kind === 'task-library' ? { relativePath: '__task_dashboard__', name: '统一任务工作台', kind: 'task-dashboard' } : null; const children = virtualNode ? [{ id: encodeNodeId(library.id, virtualNode.relativePath), name: virtualNode.name, type: 'file', kind: virtualNode.kind, virtual: true }, ...fileChildren] : fileChildren; return { id: encodeNodeId(library.id, ''), libraryId: library.id, name: library.label, description: library.description, type: 'folder', kind: library.kind, path: library.root, children, fileCount: fileChildren.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0), }; }); const properties = allProperties(); return { status: 'ok', data: { catalogVersion: catalog.version, roots, summary: { libraryCount: roots.length, fileCount: roots.reduce((sum, item) => sum + item.fileCount, 0), propertyCount: properties.length, skillCount: listSkillRegistry().data.summary.skillCount, }, }, }; } function readKnowledgeFile(nodeId) { const { library, filePath, relativePath } = safeNodePath(nodeId); if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) throw new Error('知识库文件不存在'); const extension = path.extname(filePath).toLowerCase(); if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) throw new Error('不支持预览该文件'); const stats = fs.statSync(filePath); if (stats.size > MAX_PREVIEW_BYTES) throw new Error('文件超过 2MB,请缩小后再预览'); const kind = fileKind(filePath); const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); let structured = null; if (extension === '.json') { try { structured = JSON.parse(content); } catch {} } return { status: 'ok', data: { id: nodeId, name: path.basename(filePath), kind, library: library.label, relativePath: relativePath.replace(/\\/g, '/'), size: stats.size, modifiedAt: stats.mtime.toISOString(), content, structured, propertyStats: kind === 'property-dataset' ? propertyStats(allProperties()) : null, }, }; } function listProperties(query = {}) { const q = String(query.q || '').trim().toLowerCase(); const district = String(query.district || '').trim(); const layout = String(query.layout || '').trim(); const decoration = String(query.decoration || '').trim(); const maxPrice = Number(query.maxPrice || 0); const page = Math.max(1, Number(query.page || 1) || 1); const pageSize = Math.max(6, Math.min(60, Number(query.pageSize || 18) || 18)); const all = allProperties(); const filtered = all.filter(item => { const haystack = `${item.id} ${item.community} ${item.district} ${item.layout} ${(item.highlights || []).join(' ')}`.toLowerCase(); if (q && !haystack.includes(q)) return false; if (district && !item.district.includes(district)) return false; if (layout && item.layout !== layout) return false; if (decoration && item.decoration !== decoration) return false; if (maxPrice && item.totalPrice > maxPrice) return false; return true; }); const offset = (page - 1) * pageSize; return { status: 'ok', data: { items: filtered.slice(offset, offset + pageSize), total: filtered.length, page, pageSize, pageCount: Math.max(1, Math.ceil(filtered.length / pageSize)), stats: propertyStats(all), source: propertySourcePath(), sourceLabel: '演示房源数据集', }, }; } function getProperty(propertyId) { const property = allProperties().find(item => item.id === String(propertyId)); if (!property) throw new Error('房源不存在'); return { status: 'ok', data: { property, source: propertySourcePath(), sourceLabel: '演示房源数据集' } }; } function parseSkillFrontmatter(content, folderName) { const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/); const frontmatter = block?.[1] || ''; const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() || folderName; const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() || ''; const title = content.match(/^#\s+(.+)$/m)?.[1]?.trim() || name; return { name, description, title }; } function listSkillRegistry() { const packages = packageDefinitions().map(pkg => { const skillsRoot = path.join(pkg.root, 'skills'); const skills = fs.existsSync(skillsRoot) ? fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => { const skillFile = path.join(skillsRoot, entry.name, 'SKILL.md'); if (!fs.existsSync(skillFile)) return null; const content = fs.readFileSync(skillFile, 'utf8').replace(/^\uFEFF/, ''); const meta = parseSkillFrontmatter(content, entry.name); return { id: `${pkg.id}:${entry.name}`, packageId: pkg.id, folder: entry.name, name: meta.name, title: meta.title, description: meta.description, tags: CAPABILITY_TAGS[entry.name] || ['Skill/MCP'], page: PAGE_BY_SKILL[entry.name] || '', status: pkg.id === 'unified-source' ? 'integrated' : pkg.status, content, filePath: skillFile, }; }).filter(Boolean) : []; return { id: pkg.id, label: pkg.label, description: pkg.description, status: pkg.status, root: pkg.root, skillCount: skills.length, skills, }; }); const properties = allProperties(); packages.push({ id: 'huaxiang-property-matching', label: '花巷房源匹配项目', description: '房源数据、客户样本、标签体系与多维匹配引擎。', status: 'data-connected', root: path.dirname(propertySourcePath()), skillCount: 1, skills: [{ id: 'huaxiang-property-matching:property-matching', packageId: 'huaxiang-property-matching', folder: 'property-matching', name: 'property-matching', title: '房源智能匹配', description: `已接入 ${properties.length} 套演示房源、客户样本、标签和匹配引擎,可作为 Agent 的业务工具与知识数据源。`, tags: ['房源列表', '客户画像', '多维评分'], page: 'knowledge', status: 'integrated', content: '# 房源智能匹配\n\n房源数据与匹配引擎已经接入 4320 的知识库页面。\n\n- 数据源:`properties.json`\n- 客户样本:`buyers.json`\n- 标签体系:`buyer-tags.json`\n- 匹配引擎:`match-engine.js`\n', filePath: propertySourcePath(), }], }); const allSkills = packages.flatMap(item => item.skills); const uniqueNames = new Set(allSkills.map(item => item.name)); return { status: 'ok', data: { packages, summary: { packageCount: packages.length, skillCount: allSkills.length, uniqueCapabilityCount: uniqueNames.size, integratedCount: allSkills.filter(item => item.status === 'integrated').length, }, }, }; } function getSkillDetail(skillId) { const registry = listSkillRegistry().data; const skill = registry.packages.flatMap(item => item.skills.map(entry => ({ ...entry, package: item.label, packageRoot: item.root }))) .find(item => item.id === skillId); if (!skill) throw new Error('技能不存在'); return { status: 'ok', data: skill }; } module.exports = { PROJECT_ROOT, PACKAGE_ROOT, KNOWLEDGE_BASE_DIR, listKnowledgeTree, readKnowledgeFile, listProperties, getProperty, listSkillRegistry, getSkillDetail, };