workspace-library-service.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. const fs = require('fs');
  2. const path = require('path');
  3. const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
  4. const KNOWLEDGE_BASE_DIR = path.join(PROJECT_ROOT, 'knowledge-base');
  5. const CATALOG_FILE = path.join(KNOWLEDGE_BASE_DIR, 'catalog.json');
  6. const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '..', '..');
  7. const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.csv', '.txt', '.js']);
  8. const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
  9. const PAGE_BY_SKILL = {
  10. 'qiwei-real-estate-auto-reply': 'agent',
  11. 'qiwei-agent-supervisor': 'agent',
  12. 'qiwei-group-management': 'groups',
  13. 'qiwei-customer-ops': 'customer-ops',
  14. 'qiwei-portrait-tags': 'portraits',
  15. 'qiwei-customer-transfer': 'transfers',
  16. 'qiwei-official-meeting': 'knowledge',
  17. 'qiwei-official-doc': 'knowledge',
  18. 'qiwei-official-todo': 'knowledge',
  19. 'qiwei-login': 'status',
  20. 'qiwei-dashboard': 'status',
  21. };
  22. const CAPABILITY_TAGS = {
  23. 'qiwei-api-catalog': ['100+ API', '接口文档', '通用调用'],
  24. 'qiwei-broker-playbook': ['顾问策略', '批量生成', '导出'],
  25. 'qiwei-capability-router': ['能力路由', '双通道', '自动选择'],
  26. 'qiwei-customer-ops': ['批量加好友', '建群', '欢迎语'],
  27. 'qiwei-customer-transfer': ['客户交接', '预览', '执行'],
  28. 'qiwei-dashboard': ['本地工作台', '状态管理'],
  29. 'qiwei-group-management': ['群识别', '群同步', '历史消息'],
  30. 'qiwei-login': ['扫码登录', '订阅', '席位'],
  31. 'qiwei-official-doc': ['文档知识库', 'Markdown', '官方 CLI'],
  32. 'qiwei-official-meeting': ['会议同步', 'AI 知识沉淀', '官方 CLI'],
  33. 'qiwei-official-todo': ['待办同步', '任务推进', '官方 CLI'],
  34. 'qiwei-portrait-tags': ['客户画像', '标签', '批量导出'],
  35. 'qiwei-real-estate-auto-reply': ['房产 Agent', '需求识别', '房源工具'],
  36. 'qiwei-agent-supervisor': ['人工监管', '审核', '审计'],
  37. 'qiwei-voice': ['语音下载', '解码', '转写'],
  38. 'qiwei-webhook-relay': ['Webhook', 'Relay', '长轮询'],
  39. };
  40. function readJson(filePath, fallback = {}) {
  41. try { return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); }
  42. catch { return fallback; }
  43. }
  44. function packageDefinitions() {
  45. const definitions = [
  46. {
  47. id: 'unified-source',
  48. label: '统一源码版',
  49. description: '当前 4320 Dashboard 的主运行包,承载页面、MCP 工具和真实企微 Agent。',
  50. root: PROJECT_ROOT,
  51. status: 'running',
  52. },
  53. {
  54. id: 'openclaw',
  55. label: 'OpenClaw 企微技能包',
  56. description: 'OpenClaw 版本的企微技能与独立 Agent 工具实现。',
  57. root: path.resolve(WORKSPACE_ROOT, '..', 'openclaw-voc-skill', 'claude-code', 'claude-code-qiwe-assistant'),
  58. status: 'connected',
  59. },
  60. {
  61. id: 'agent-workbench',
  62. label: '企微 Agent Workbench',
  63. description: 'SQLite 状态机、人工审核、暂停、审计和工具循环的来源实现。',
  64. root: path.join(WORKSPACE_ROOT, 'qiwei-agent-workbench'),
  65. status: 'merged',
  66. },
  67. ];
  68. const seenRoots = new Set();
  69. return definitions.filter(item => {
  70. if (!fs.existsSync(item.root)) return false;
  71. const root = path.resolve(item.root).toLowerCase();
  72. if (seenRoots.has(root)) return false;
  73. seenRoots.add(root);
  74. return true;
  75. });
  76. }
  77. function loadCatalog() {
  78. const parsed = readJson(CATALOG_FILE, { version: 1, libraries: [] });
  79. const libraries = (parsed.libraries || []).map(item => ({
  80. ...item,
  81. root: path.resolve(KNOWLEDGE_BASE_DIR, item.path),
  82. extensions: new Set((item.extensions || []).map(value => String(value).toLowerCase())),
  83. kind: item.id === 'property-data'
  84. ? 'property-library'
  85. : item.id === 'meeting-knowledge'
  86. ? 'meeting-library'
  87. : item.id === 'doc-knowledge'
  88. ? 'doc-library'
  89. : item.id === 'todo-knowledge'
  90. ? 'todo-library'
  91. : item.id === 'task-knowledge'
  92. ? 'task-library'
  93. : 'document-library',
  94. })).filter(item => item.id !== 'property-data' || fs.existsSync(item.root));
  95. for (const pkg of packageDefinitions()) {
  96. const skillsRoot = path.join(pkg.root, 'skills');
  97. if (!fs.existsSync(skillsRoot)) continue;
  98. libraries.push({
  99. id: `skills-${pkg.id}`,
  100. label: `${pkg.label} · 技能说明`,
  101. description: 'SKILL.md 与 references 文档',
  102. root: skillsRoot,
  103. extensions: new Set(['.md', '.json', '.txt']),
  104. kind: 'skill-library',
  105. });
  106. }
  107. return { version: parsed.version || 1, libraries };
  108. }
  109. function encodeNodeId(libraryId, relativePath) {
  110. return `${libraryId}~${Buffer.from(String(relativePath || ''), 'utf8').toString('base64url')}`;
  111. }
  112. function decodeNodeId(nodeId) {
  113. const index = String(nodeId || '').indexOf('~');
  114. if (index < 1) throw new Error('知识库节点不存在');
  115. const libraryId = nodeId.slice(0, index);
  116. const relativePath = Buffer.from(nodeId.slice(index + 1), 'base64url').toString('utf8');
  117. return { libraryId, relativePath };
  118. }
  119. function safeNodePath(nodeId) {
  120. const { libraryId, relativePath } = decodeNodeId(nodeId);
  121. const library = loadCatalog().libraries.find(item => item.id === libraryId);
  122. if (!library) throw new Error('知识库目录不存在');
  123. const resolved = path.resolve(library.root, relativePath);
  124. const rootPrefix = `${path.resolve(library.root).toLowerCase()}${path.sep}`;
  125. if (resolved.toLowerCase() !== path.resolve(library.root).toLowerCase() && !resolved.toLowerCase().startsWith(rootPrefix)) {
  126. throw new Error('禁止访问知识库目录之外的文件');
  127. }
  128. return { library, filePath: resolved, relativePath };
  129. }
  130. function fileKind(filePath) {
  131. const base = path.basename(filePath).toLowerCase();
  132. const extension = path.extname(filePath).toLowerCase();
  133. if (base === 'properties.json') return 'property-dataset';
  134. if (base === 'skill.md') return 'skill-document';
  135. if (extension === '.md') return 'markdown';
  136. if (extension === '.json') return 'json';
  137. if (extension === '.csv') return 'csv';
  138. if (extension === '.js') return 'code';
  139. return 'text';
  140. }
  141. function buildDirectoryChildren(library, directory, relativeDir = '', depth = 0) {
  142. if (depth > 7 || !fs.existsSync(directory)) return [];
  143. const entries = fs.readdirSync(directory, { withFileTypes: true })
  144. .filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules')
  145. .map(entry => {
  146. const relativePath = path.join(relativeDir, entry.name);
  147. const fullPath = path.join(directory, entry.name);
  148. if (entry.isDirectory()) {
  149. const children = buildDirectoryChildren(library, fullPath, relativePath, depth + 1);
  150. if (!children.length) return null;
  151. return {
  152. id: encodeNodeId(library.id, relativePath),
  153. name: entry.name,
  154. type: 'folder',
  155. children,
  156. fileCount: children.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0),
  157. };
  158. }
  159. const extension = path.extname(entry.name).toLowerCase();
  160. if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) return null;
  161. const stats = fs.statSync(fullPath);
  162. return {
  163. id: encodeNodeId(library.id, relativePath),
  164. name: entry.name,
  165. type: 'file',
  166. kind: fileKind(fullPath),
  167. size: stats.size,
  168. modifiedAt: stats.mtime.toISOString(),
  169. relativePath: relativePath.replace(/\\/g, '/'),
  170. };
  171. })
  172. .filter(Boolean);
  173. return entries.sort((a, b) => {
  174. if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
  175. return a.name.localeCompare(b.name, 'zh-CN');
  176. });
  177. }
  178. function propertySourcePath() {
  179. const library = loadCatalog().libraries.find(item => item.id === 'property-data');
  180. if (!library) throw new Error('房源数据目录未配置');
  181. const filePath = path.join(library.root, 'properties.json');
  182. if (!fs.existsSync(filePath)) throw new Error('properties.json 不存在');
  183. return filePath;
  184. }
  185. function normalizeProperty(raw) {
  186. return {
  187. id: String(raw.id || raw.objectId || raw.property_id || ''),
  188. community: raw.community || raw.community_name || '',
  189. district: raw.district || raw.area_name || '',
  190. totalPrice: Number(raw.totalPrice ?? raw.total_price ?? raw.price_total ?? 0),
  191. unitPrice: Number(raw.unitPrice ?? raw.unit_price ?? raw.price_unit ?? 0),
  192. layout: raw.layout || raw.house_type || '',
  193. area: Number(raw.area || 0),
  194. floor: raw.floor || raw.floor_info || '',
  195. floorLevel: raw.floorLevel || raw.floor_level || '',
  196. orientation: raw.orientation || '',
  197. decoration: raw.decoration || '',
  198. buildingAge: Number(raw.buildingAge ?? raw.building_age ?? 0),
  199. isSchoolDistrict: Boolean(raw.isSchoolDistrict ?? raw.is_school_district),
  200. schoolName: raw.schoolName || raw.school_name || '',
  201. parking: raw.parking || '',
  202. surrounding: raw.surrounding || '',
  203. ownerSituation: raw.ownerSituation || raw.owner_situation || '',
  204. priceDropSpace: Number(raw.priceDropSpace ?? raw.price_drop_space ?? 0),
  205. isFiveYearOnly: Boolean(raw.isFiveYearOnly ?? raw.is_five_year_only),
  206. highlights: raw.highlightTags || raw.highlight_tags || raw.tags || [],
  207. scores: {
  208. community: Number(raw.communityQuality || 0),
  209. transport: Number(raw.transportScore || 0),
  210. surrounding: Number(raw.surroundingScore || 0),
  211. priceAdvantage: Number(raw.priceAdvantage || 0),
  212. },
  213. };
  214. }
  215. function allProperties() {
  216. const parsed = readJson(propertySourcePath(), {});
  217. const rows = Array.isArray(parsed) ? parsed : (parsed.properties || parsed.data || []);
  218. return rows.map(normalizeProperty).filter(item => item.id || item.community);
  219. }
  220. function propertyStats(properties) {
  221. const prices = properties.map(item => item.totalPrice).filter(value => value > 0);
  222. return {
  223. total: properties.length,
  224. districts: [...new Set(properties.map(item => item.district.split('-')[0]).filter(Boolean))].sort(),
  225. layouts: [...new Set(properties.map(item => item.layout).filter(Boolean))].sort(),
  226. decorations: [...new Set(properties.map(item => item.decoration).filter(Boolean))].sort(),
  227. minPrice: prices.length ? Math.min(...prices) : 0,
  228. maxPrice: prices.length ? Math.max(...prices) : 0,
  229. averagePrice: prices.length ? Math.round(prices.reduce((sum, value) => sum + value, 0) / prices.length) : 0,
  230. schoolDistrictCount: properties.filter(item => item.isSchoolDistrict).length,
  231. };
  232. }
  233. function listKnowledgeTree() {
  234. const catalog = loadCatalog();
  235. const roots = catalog.libraries.map(library => {
  236. const fileChildren = buildDirectoryChildren(library, library.root);
  237. const virtualNode = library.kind === 'meeting-library'
  238. ? { relativePath: '__meeting_dashboard__', name: '会议工作台', kind: 'meeting-dashboard' }
  239. : library.kind === 'doc-library'
  240. ? { relativePath: '__doc_dashboard__', name: '文档工作台', kind: 'doc-dashboard' }
  241. : library.kind === 'todo-library'
  242. ? { relativePath: '__todo_dashboard__', name: '待办中心', kind: 'todo-dashboard' }
  243. : library.kind === 'task-library'
  244. ? { relativePath: '__task_dashboard__', name: '统一任务工作台', kind: 'task-dashboard' }
  245. : null;
  246. const children = virtualNode
  247. ? [{ id: encodeNodeId(library.id, virtualNode.relativePath), name: virtualNode.name, type: 'file', kind: virtualNode.kind, virtual: true }, ...fileChildren]
  248. : fileChildren;
  249. return {
  250. id: encodeNodeId(library.id, ''),
  251. libraryId: library.id,
  252. name: library.label,
  253. description: library.description,
  254. type: 'folder',
  255. kind: library.kind,
  256. path: library.root,
  257. children,
  258. fileCount: fileChildren.reduce((sum, item) => sum + (item.type === 'file' ? 1 : item.fileCount || 0), 0),
  259. };
  260. });
  261. const properties = allProperties();
  262. return {
  263. status: 'ok',
  264. data: {
  265. catalogVersion: catalog.version,
  266. roots,
  267. summary: {
  268. libraryCount: roots.length,
  269. fileCount: roots.reduce((sum, item) => sum + item.fileCount, 0),
  270. propertyCount: properties.length,
  271. skillCount: listSkillRegistry().data.summary.skillCount,
  272. },
  273. },
  274. };
  275. }
  276. function readKnowledgeFile(nodeId) {
  277. const { library, filePath, relativePath } = safeNodePath(nodeId);
  278. if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) throw new Error('知识库文件不存在');
  279. const extension = path.extname(filePath).toLowerCase();
  280. if (!ALLOWED_EXTENSIONS.has(extension) || !library.extensions.has(extension)) throw new Error('不支持预览该文件');
  281. const stats = fs.statSync(filePath);
  282. if (stats.size > MAX_PREVIEW_BYTES) throw new Error('文件超过 2MB,请缩小后再预览');
  283. const kind = fileKind(filePath);
  284. const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
  285. let structured = null;
  286. if (extension === '.json') {
  287. try { structured = JSON.parse(content); } catch {}
  288. }
  289. return {
  290. status: 'ok',
  291. data: {
  292. id: nodeId,
  293. name: path.basename(filePath),
  294. kind,
  295. library: library.label,
  296. relativePath: relativePath.replace(/\\/g, '/'),
  297. size: stats.size,
  298. modifiedAt: stats.mtime.toISOString(),
  299. content,
  300. structured,
  301. propertyStats: kind === 'property-dataset' ? propertyStats(allProperties()) : null,
  302. },
  303. };
  304. }
  305. function listProperties(query = {}) {
  306. const q = String(query.q || '').trim().toLowerCase();
  307. const district = String(query.district || '').trim();
  308. const layout = String(query.layout || '').trim();
  309. const decoration = String(query.decoration || '').trim();
  310. const maxPrice = Number(query.maxPrice || 0);
  311. const page = Math.max(1, Number(query.page || 1) || 1);
  312. const pageSize = Math.max(6, Math.min(60, Number(query.pageSize || 18) || 18));
  313. const all = allProperties();
  314. const filtered = all.filter(item => {
  315. const haystack = `${item.id} ${item.community} ${item.district} ${item.layout} ${(item.highlights || []).join(' ')}`.toLowerCase();
  316. if (q && !haystack.includes(q)) return false;
  317. if (district && !item.district.includes(district)) return false;
  318. if (layout && item.layout !== layout) return false;
  319. if (decoration && item.decoration !== decoration) return false;
  320. if (maxPrice && item.totalPrice > maxPrice) return false;
  321. return true;
  322. });
  323. const offset = (page - 1) * pageSize;
  324. return {
  325. status: 'ok',
  326. data: {
  327. items: filtered.slice(offset, offset + pageSize),
  328. total: filtered.length,
  329. page,
  330. pageSize,
  331. pageCount: Math.max(1, Math.ceil(filtered.length / pageSize)),
  332. stats: propertyStats(all),
  333. source: propertySourcePath(),
  334. sourceLabel: '演示房源数据集',
  335. },
  336. };
  337. }
  338. function getProperty(propertyId) {
  339. const property = allProperties().find(item => item.id === String(propertyId));
  340. if (!property) throw new Error('房源不存在');
  341. return { status: 'ok', data: { property, source: propertySourcePath(), sourceLabel: '演示房源数据集' } };
  342. }
  343. function parseSkillFrontmatter(content, folderName) {
  344. const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
  345. const frontmatter = block?.[1] || '';
  346. const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim() || folderName;
  347. const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() || '';
  348. const title = content.match(/^#\s+(.+)$/m)?.[1]?.trim() || name;
  349. return { name, description, title };
  350. }
  351. function listSkillRegistry() {
  352. const packages = packageDefinitions().map(pkg => {
  353. const skillsRoot = path.join(pkg.root, 'skills');
  354. const skills = fs.existsSync(skillsRoot)
  355. ? fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => {
  356. const skillFile = path.join(skillsRoot, entry.name, 'SKILL.md');
  357. if (!fs.existsSync(skillFile)) return null;
  358. const content = fs.readFileSync(skillFile, 'utf8').replace(/^\uFEFF/, '');
  359. const meta = parseSkillFrontmatter(content, entry.name);
  360. return {
  361. id: `${pkg.id}:${entry.name}`,
  362. packageId: pkg.id,
  363. folder: entry.name,
  364. name: meta.name,
  365. title: meta.title,
  366. description: meta.description,
  367. tags: CAPABILITY_TAGS[entry.name] || ['Skill/MCP'],
  368. page: PAGE_BY_SKILL[entry.name] || '',
  369. status: pkg.id === 'unified-source' ? 'integrated' : pkg.status,
  370. content,
  371. filePath: skillFile,
  372. };
  373. }).filter(Boolean)
  374. : [];
  375. return {
  376. id: pkg.id,
  377. label: pkg.label,
  378. description: pkg.description,
  379. status: pkg.status,
  380. root: pkg.root,
  381. skillCount: skills.length,
  382. skills,
  383. };
  384. });
  385. const properties = allProperties();
  386. packages.push({
  387. id: 'huaxiang-property-matching',
  388. label: '花巷房源匹配项目',
  389. description: '房源数据、客户样本、标签体系与多维匹配引擎。',
  390. status: 'data-connected',
  391. root: path.dirname(propertySourcePath()),
  392. skillCount: 1,
  393. skills: [{
  394. id: 'huaxiang-property-matching:property-matching',
  395. packageId: 'huaxiang-property-matching',
  396. folder: 'property-matching',
  397. name: 'property-matching',
  398. title: '房源智能匹配',
  399. description: `已接入 ${properties.length} 套演示房源、客户样本、标签和匹配引擎,可作为 Agent 的业务工具与知识数据源。`,
  400. tags: ['房源列表', '客户画像', '多维评分'],
  401. page: 'knowledge',
  402. status: 'integrated',
  403. content: '# 房源智能匹配\n\n房源数据与匹配引擎已经接入 4320 的知识库页面。\n\n- 数据源:`properties.json`\n- 客户样本:`buyers.json`\n- 标签体系:`buyer-tags.json`\n- 匹配引擎:`match-engine.js`\n',
  404. filePath: propertySourcePath(),
  405. }],
  406. });
  407. const allSkills = packages.flatMap(item => item.skills);
  408. const uniqueNames = new Set(allSkills.map(item => item.name));
  409. return {
  410. status: 'ok',
  411. data: {
  412. packages,
  413. summary: {
  414. packageCount: packages.length,
  415. skillCount: allSkills.length,
  416. uniqueCapabilityCount: uniqueNames.size,
  417. integratedCount: allSkills.filter(item => item.status === 'integrated').length,
  418. },
  419. },
  420. };
  421. }
  422. function getSkillDetail(skillId) {
  423. const registry = listSkillRegistry().data;
  424. const skill = registry.packages.flatMap(item => item.skills.map(entry => ({ ...entry, package: item.label, packageRoot: item.root })))
  425. .find(item => item.id === skillId);
  426. if (!skill) throw new Error('技能不存在');
  427. return { status: 'ok', data: skill };
  428. }
  429. module.exports = {
  430. PROJECT_ROOT,
  431. KNOWLEDGE_BASE_DIR,
  432. listKnowledgeTree,
  433. readKnowledgeFile,
  434. listProperties,
  435. getProperty,
  436. listSkillRegistry,
  437. getSkillDetail,
  438. };