workspace-library-service.js 19 KB

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