@linkaicli/linkai-cli 0.0.0-stage → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +64 -0
  2. package/GUIDE.md +149 -0
  3. package/LICENSE +190 -0
  4. package/NOTICE +6 -0
  5. package/README.md +82 -2
  6. package/THIRD_PARTY_NOTICES.txt +318 -0
  7. package/cli-manifest.json +1850 -0
  8. package/clis/_shared/common.js +1 -0
  9. package/clis/_shared/site-auth.js +1 -0
  10. package/clis/linkai/build.js +1 -0
  11. package/clis/linkai/card-rules.js +1 -0
  12. package/clis/linkai/collab.js +1 -0
  13. package/clis/linkai/commands.js +4 -0
  14. package/clis/linkai/core.js +12 -0
  15. package/clis/linkai/deliverable.js +2 -0
  16. package/clis/linkai/dictionary.js +1 -0
  17. package/clis/linkai/fill-card.js +1 -0
  18. package/clis/linkai/oauth.js +8 -0
  19. package/clis/linkai/reuse.js +1 -0
  20. package/clis/linkai/templates.js +1 -0
  21. package/dist/bundle/browser-cdp.mjs +2 -0
  22. package/dist/bundle/browser-page.mjs +2 -0
  23. package/dist/bundle/browser-utils.mjs +2 -0
  24. package/dist/bundle/core.mjs +3448 -0
  25. package/dist/bundle/daemon.mjs +16 -0
  26. package/dist/bundle/download-article.mjs +2 -0
  27. package/dist/bundle/download-media.mjs +2 -0
  28. package/dist/bundle/download-progress.mjs +2 -0
  29. package/dist/bundle/download.mjs +2 -0
  30. package/dist/bundle/errors.mjs +2 -0
  31. package/dist/bundle/logger.mjs +2 -0
  32. package/dist/bundle/main.mjs +3 -0
  33. package/dist/bundle/oplog.mjs +2 -0
  34. package/dist/bundle/pipeline.mjs +2 -0
  35. package/dist/bundle/registry.mjs +2 -0
  36. package/dist/bundle/skills.mjs +2 -0
  37. package/dist/bundle/types.mjs +2 -0
  38. package/dist/bundle/utils.mjs +2 -0
  39. package/dist/bundle/vendor/readability/Readability-readerable.js +122 -0
  40. package/dist/bundle/vendor/readability/Readability.js +2786 -0
  41. package/knowledge/README.md +29 -0
  42. package/knowledge/browser/README.md +40 -0
  43. package/knowledge/data-dictionary/README.md +35 -0
  44. package/knowledge/data-dictionary/_templates/enum.md +7 -0
  45. package/knowledge/data-dictionary/_templates/metric.md +5 -0
  46. package/knowledge/data-dictionary/_templates/view.md +29 -0
  47. package/knowledge/data-dictionary/enums/.gitkeep +1 -0
  48. package/knowledge/data-dictionary/index.md +12 -0
  49. package/knowledge/data-dictionary/metrics/.gitkeep +1 -0
  50. package/knowledge/data-dictionary/views/.gitkeep +1 -0
  51. package/knowledge/report-builder/README.md +80 -0
  52. package/knowledge/report-builder/SOP.md +91 -0
  53. package/knowledge/report-builder/build-spec.md +79 -0
  54. package/knowledge/report-builder/references/api-catalog.md +106 -0
  55. package/knowledge/report-builder/references/api.md +148 -0
  56. package/knowledge/report-builder/references/auth-space.md +58 -0
  57. package/knowledge/report-builder/references/card.md +382 -0
  58. package/knowledge/report-builder/references/chart-config-schema.md +572 -0
  59. package/knowledge/report-builder/references/chart-types.md +167 -0
  60. package/knowledge/report-builder/references/dashboard-patterns.md +138 -0
  61. package/knowledge/report-builder/references/dataset.md +152 -0
  62. package/knowledge/report-builder/references/fill-import.md +95 -0
  63. package/knowledge/report-builder/references/filters.md +170 -0
  64. package/knowledge/report-builder/references/gantt.md +79 -0
  65. package/knowledge/report-builder/references/pitfalls.md +265 -0
  66. package/knowledge/report-builder/references/query-engine.md +177 -0
  67. package/knowledge/report-builder/references/report.md +249 -0
  68. package/llms.txt +37 -0
  69. package/package.json +108 -4
  70. package/skills/agent-prompt.md +37 -0
  71. package/skills/linkai-browser/SKILL.md +67 -0
  72. package/skills/linkai-collab/SKILL.md +65 -0
  73. package/skills/linkai-data-workflow/SKILL.md +49 -0
  74. package/skills/linkai-data-workflow/assets/import-run-template.json +22 -0
  75. package/skills/linkai-data-workflow/assets/solution-template.md +60 -0
  76. package/skills/linkai-data-workflow/references/excel-analysis-modeling.md +35 -0
  77. package/skills/linkai-data-workflow/references/fill-import.md +95 -0
  78. package/skills/linkai-data-workflow/references/solution-knowledge.md +35 -0
  79. package/skills/linkai-report-builder/SKILL.md +151 -0
  80. package/skills/linkai-usage/SKILL.md +67 -0
  81. package/templates/report-spec/cards/detail-table.json +12 -0
  82. package/templates/report-spec/filters.example.json +28 -0
  83. package/templates/report-spec/plan-knowledge.example.yaml +18 -0
  84. package/templates/report-spec/report.example.yaml +123 -0
  85. package/templates/report-spec/sql/channel.sql +7 -0
  86. package/templates/report-spec/sql/detail.sql +10 -0
  87. package/templates/report-spec/sql/kpi.sql +8 -0
  88. package/templates/report-spec/sql/trend.sql +9 -0
@@ -0,0 +1 @@
1
+ import{ArgumentError}from"@linkaicli/linkai-cli/errors";function clamp(value,min,max){return Math.max(min,Math.min(value,max))}function clampInt(raw,fallback,min,max){const parsed=Number(raw);return Number.isFinite(parsed)?clamp(Math.floor(parsed),min,max):fallback}function normalizeNumericId(value,label,example){const normalized=String(value??"").trim();if(!/^\d+$/.test(normalized))throw new ArgumentError(`${label} must be a numeric ID`,`Pass a numeric ${label}, for example: ${example}`);return normalized}function requireNonEmptyQuery(value,label="query"){const normalized=String(value??"").trim();if(!normalized)throw new ArgumentError(`${label} cannot be empty`);return normalized}export{clamp,clampInt,normalizeNumericId,requireNonEmptyQuery};
@@ -0,0 +1 @@
1
+ import{AuthRequiredError,CommandExecutionError,TimeoutError,getErrorMessage}from"@linkaicli/linkai-cli/errors";import{cli,Strategy}from"@linkaicli/linkai-cli/registry";const DEFAULT_TIMEOUT_SECONDS=300,POLL_INTERVAL_MS=2e3;function normalizeIdentity(site,identity){if(!identity||typeof identity!="object"||Array.isArray(identity)||Object.keys(identity).length===0||"logged_in"in identity&&typeof identity.logged_in!="boolean")throw new CommandExecutionError(`${site} identity probe returned an invalid account result`);if(identity.logged_in===!1)throw new AuthRequiredError(site,"Identity probe reports a logged-out session");return{...identity,logged_in:!0,site}}function isAuthRequired(error){return error instanceof AuthRequiredError}async function tryProbe(config,page,phase){const probe=phase==="poll"&&config.poll?config.poll:config.verify;return normalizeIdentity(config.site,await probe(page,{phase}))}function authHint(config){return`Run \`linkai ${config.site} login\` to open the login page, then retry.`}function commandColumns(config){return["logged_in","site",...config.columns??["id","username","name"]]}function normalizeQuickCheck(result){return typeof result=="boolean"?{logged_in:result}:result&&typeof result=="object"&&!Array.isArray(result)?{...result,logged_in:typeof result.logged_in=="boolean"?result.logged_in:null}:{logged_in:null}}function normalizeRefreshResult(result){return result&&typeof result=="object"&&!Array.isArray(result)?result:{touched:!0}}function registerSiteAuthCommands(config){if(!config?.site||!config?.domain||!config?.loginUrl||typeof config.verify!="function")throw new Error("registerSiteAuthCommands requires site, domain, loginUrl, and verify(page)");config.registerWhoami!==!1&&cli({site:config.site,name:"whoami",access:"read",description:config.whoamiDescription??`Show the current logged-in ${config.site} account`,domain:config.domain,strategy:Strategy.COOKIE,browser:!0,navigateBefore:!1,siteSession:"persistent",args:[],columns:commandColumns(config),authStatus:{...typeof config.quickCheck=="function"?{quickCheck:async page=>normalizeQuickCheck(await config.quickCheck(page))}:{},...typeof config.refresh=="function"?{refresh:async(page,kwargs)=>normalizeRefreshResult(await config.refresh(page,kwargs))}:{}},func:async page=>tryProbe(config,page,"identity")}),cli({site:config.site,name:"login",access:"write",description:config.loginDescription??`Open ${config.site} login and wait until the browser session is authenticated`,domain:config.domain,strategy:Strategy.COOKIE,browser:!0,navigateBefore:!1,defaultWindowMode:"foreground",siteSession:"persistent",args:[{name:"timeout",type:"int",default:DEFAULT_TIMEOUT_SECONDS,help:"Maximum seconds to wait for the user to finish login"}],columns:["status",...commandColumns(config)],func:async(page,kwargs)=>{try{return{status:"already_logged_in",...await tryProbe(config,page,"identity")}}catch(error){if(!isAuthRequired(error))throw error}await page.goto(config.loginUrl);const timeoutSeconds=Number(kwargs.timeout??DEFAULT_TIMEOUT_SECONDS),deadline=Date.now()+timeoutSeconds*1e3;let lastAuthMessage="";for(;Date.now()<deadline;){await page.wait(Math.min(POLL_INTERVAL_MS/1e3,Math.max(.2,(deadline-Date.now())/1e3)));try{return{status:"login_complete",...await tryProbe(config,page,"poll")}}catch(error){if(!isAuthRequired(error))throw error;lastAuthMessage=getErrorMessage(error)}}throw new TimeoutError(`${config.site} login`,timeoutSeconds,lastAuthMessage?`${authHint(config)} Last auth check: ${lastAuthMessage}`:authHint(config))}})}export{registerSiteAuthCommands};
@@ -0,0 +1 @@
1
+ import fs from"node:fs";import path from"node:path";import{parseYaml}from"@linkaicli/linkai-cli/utils";import*as core from"./core.js";import*as oplog from"@linkaicli/linkai-cli/oplog";import*as reuse from"./reuse.js";import{containsFullSql,knowledgeAuthoringFromBuild,publishPlanKnowledge}from"./deliverable.js";const CARD_TYPES=new Set(["chart","meter","table","gantt","existing"]),TRANSPARENT_TYPES=new Set(["meter"]),DEFAULT_LAYOUT={contentX:2,contentWidth:20,pageBackgroundColor:"#F4F3EE",titleHeight:2},list=v=>(Array.isArray(v)?v:String(v??"").split(",")).map(s=>String(s).trim()).filter(Boolean);function currentTenantLabel(){try{const s=core.readSession();if(s){const t=core.spaceOf(s);return t.tenantName||t.tenantId}}catch{}return"(未登录:执行前请用户执行 linkai login 并在浏览器完成领谋授权)"}function loadSpec(file){const abs=path.resolve(String(file)),text=fs.readFileSync(abs,"utf8"),spec=/\.json$/i.test(abs)?JSON.parse(text):parseYaml(text),baseDir=path.dirname(abs);for(const c of spec?.cards||[])(c.type==="table"||c.type==="gantt")&&typeof c.spec=="string"&&(c.specFile=c.spec,c.spec=JSON.parse(fs.readFileSync(path.resolve(baseDir,c.spec),"utf8")));return{spec,specFile:abs,baseDir}}function validateSpec(spec){const errors=[],need=(cond,msg)=>{cond||errors.push(msg)};if(need(spec&&typeof spec=="object","spec 必须是对象"),errors.length)return errors;need(spec.version===1,"version 必须为 1"),need(spec.operation,"operation(create 或 update)必填;用户未说明时先主动询问"),need(spec.operation==="create","linkai build 当前用于新建方案,operation 必须为 create;修改原方案请定位 planId 后按知识库规则更新并执行 linkai plan-knowledge --mode update"),need(typeof spec.name=="string"&&spec.name.trim(),"name(方案名)必填"),need(spec.knowledge?.summary,"knowledge.summary(方案内容)必填"),need(spec.knowledge?.problem,"knowledge.problem(解决的问题)必填"),need(Array.isArray(spec.cards)&&spec.cards.length>0,"cards 至少需要一张业务卡片"),need(spec.reuse===void 0||reuse.REUSE_MODES.has(spec.reuse),`reuse 只能是 ${[...reuse.REUSE_MODES].join(" | ")}(默认 suggest)`);const dsKeys=new Set;for(const[i,d]of(spec.datasets||[]).entries())need(d.key,`datasets[${i}].key 必填`),need(!dsKeys.has(d.key),`datasets key 重复:${d.key}`),dsKeys.add(d.key),need(d.id||d.name&&d.sql,`datasets[${d.key}] 需要 id(复用已有数据集)或 name + sql`);const cardKeys=new Set;for(const[i,c]of(spec.cards||[]).entries()){if(need(c.key,`cards[${i}].key 必填`),need(!cardKeys.has(c.key),`cards key 重复:${c.key}`),cardKeys.add(c.key),need(CARD_TYPES.has(c.type),`cards[${c.key}].type 必须是 ${[...CARD_TYPES].join(" | ")}`),c.type==="existing"){need(c.id,`cards[${c.key}] type=existing 需要 id`),need(c.knowledge?.content,`cards[${c.key}].knowledge.content(卡片内容)必填`),need(c.knowledge?.source,`cards[${c.key}].knowledge.source(数据来源)必填`),need(c.knowledge?.grain,`cards[${c.key}].knowledge.grain(数据粒度)必填`),need(c.knowledge?.dataLogic,`cards[${c.key}].knowledge.dataLogic(取数逻辑)必填`);continue}need(dsKeys.has(c.dataset),`cards[${c.key}].dataset 引用了不存在的数据集 key:${c.dataset}`),c.type==="chart"&&(need(c.name,`cards[${c.key}].name 必填`),need(c.kind,`cards[${c.key}].kind 必填(如 column/stacked,见 linkai kinds)`),need(list(c.meas).length,`cards[${c.key}].meas 至少一个数值列`)),c.type==="meter"&&(need(c.name,`cards[${c.key}].name 必填`),need(c.col,`cards[${c.key}].col 必填`)),(c.type==="table"||c.type==="gantt")&&(need(c.spec&&typeof c.spec=="object",`cards[${c.key}].spec 必填(内联对象或 .json 路径)`),need(c.spec?.name,`cards[${c.key}].spec.name 必填`)),need(c.knowledge?.content,`cards[${c.key}].knowledge.content(卡片内容)必填`),need(c.knowledge?.source,`cards[${c.key}].knowledge.source(数据来源)必填`),need(c.knowledge?.grain,`cards[${c.key}].knowledge.grain(数据粒度)必填`),need(c.knowledge?.dataLogic,`cards[${c.key}].knowledge.dataLogic(取数逻辑)必填`)}const used=new Set;for(const[si,s]of(spec.sections||[]).entries())for(const[ri,r]of(s.rows||[]).entries()){need(Number(r.height)>0,`sections[${si}].rows[${ri}].height 必须为正整数(格)`);for(const p of r.cards||[])need(cardKeys.has(p.card),`sections[${si}].rows[${ri}] 引用了不存在的卡片 key:${p.card}`),need(!used.has(p.card),`卡片 ${p.card} 在布局中出现多次`),used.add(p.card)}for(const r of spec.publish?.reports||[])need(cardKeys.has(r.card),`publish.reports 引用了不存在的卡片 key:${r.card}`);return containsFullSql({knowledge:spec.knowledge,cards:(spec.cards||[]).map(c=>c.knowledge)})&&errors.push("方案知识库不得包含完整 SQL;cards[].knowledge.dataLogic 必须使用自然语言描述"),errors}function formalNames(spec){return[spec.name,...(spec.datasets||[]).filter(d=>!d.id).map(d=>d.name),...(spec.cards||[]).filter(c=>c.type!=="existing").map(c=>c.name||c.spec?.name),...(spec.publish?.reports||[]).map(r=>r.name)].filter(n=>n&&!String(n).startsWith("zz-"))}function planLayout(spec){const L={...DEFAULT_LAYOUT,...spec.layout||{}},right=L.contentX+L.contentWidth,blocks=[],errors=[];let y=0,mobileY=0;const push=b=>{blocks.push({...b,mobileX:0,mobileY,mobileWidth:4,mobileHeight:b.height}),mobileY+=b.height};for(const[si,s]of(spec.sections||[]).entries()){s.title&&(push({kind:"title",key:`title-${si}`,title:s.title,x:L.contentX,y,width:L.contentWidth,height:L.titleHeight}),y+=L.titleHeight);for(const[ri,r]of(s.rows||[]).entries()){const cards=r.cards||[],fixed=cards.reduce((n,p)=>n+(Number(p.width)||0),0),auto=cards.filter(p=>!p.width).length,autoW=auto?Math.floor((L.contentWidth-fixed)/auto):0;let x=L.contentX;for(const p of cards){const width=Number(p.width)||autoW;width<=0&&errors.push(`sections[${si}].rows[${ri}] 卡片 ${p.card} 宽度为 0(行内固定宽度已占满)`),push({kind:"card",key:p.card,x,y,width,height:Number(r.height)}),x+=width}x>right&&errors.push(`sections[${si}].rows[${ri}] 总宽 ${x-L.contentX} 超出内容区 ${L.contentWidth} 格`),y+=Number(r.height)}}return{layout:L,blocks,errors:[...errors,...overlaps(blocks,"pc"),...overlaps(blocks,"mobile")]}}function overlaps(blocks,mode){const rect=b=>mode==="pc"?[b.x,b.y,b.width,b.height]:[b.mobileX,b.mobileY,b.mobileWidth,b.mobileHeight],out=[];for(let i=0;i<blocks.length;i++)for(let j=i+1;j<blocks.length;j++){const[ax,ay,aw,ah]=rect(blocks[i]),[bx,by,bw,bh]=rect(blocks[j]);ax<bx+bw&&bx<ax+aw&&ay<by+bh&&by<ay+ah&&out.push(`${mode} 布局重叠:${blocks[i].key} 与 ${blocks[j].key}`)}return out}function titleContent(t){return JSON.stringify({type:"title-template",version:1,template:"section",config:{serial:String(t.serial??""),title:String(t.title??""),subtitle:String(t.subtitle??""),description:"",showDecorLine:!1}})}function describePlan(spec){const steps=[];for(const d of spec.datasets||[])steps.push(d.id?`复用数据集 ${d.key} = ${d.id}`:`建数据集 ${d.key}「${d.name}」← ${d.sql}`);for(const c of spec.cards||[]){if(c.type==="existing"){steps.push(`复用卡片 ${c.key} = ${c.id}`);continue}steps.push(`建卡片 ${c.key}(${c.type}${c.kind?` ${c.kind}`:""})「${c.name||c.spec?.name||""}」${c.sort?`,排序 ${c.sort}`:""}`)}steps.push(`建方案「${spec.name}」并按布局放入 ${(spec.sections||[]).length} 个分区`),steps.push("写页面样式(暖色背景、标题/指标卡卡位透明、隐藏外层标题)"),spec.filters&&steps.push(`写方案筛选器模板 ← ${spec.filters}`);for(const r of spec.publish?.reports||[])steps.push(`发布报表「${r.name}」← 卡片 ${r.card}`);return steps.push("验收:逐卡 query + 方案回读(卡片数、越界、重叠、背景色)"),steps.push("生成并保存版本化方案知识库(完整呈现给用户,不含 SQL)"),steps}function fillPlaceholders(text,state,dataSourceId=""){const pick=(map,kind)=>(_,k)=>{if(!map[k])throw new Error(`filters 引用了不存在或未创建的${kind} key:${k}`);return map[k]};return text.replaceAll("{{plan}}",state.planId).replaceAll("{{dataSource}}",dataSourceId).replace(/\{\{pageCard:([^}]+)\}\}/g,pick(state.blocks,"卡片")).replace(/\{\{dataset:([^}]+)\}\}/g,pick(state.datasets,"数据集"))}function readState(file){try{return JSON.parse(fs.readFileSync(file,"utf8"))}catch{return{datasets:{},cards:{},sorted:{},blocks:{},reports:{}}}}function writeState(file,state){fs.writeFileSync(file,JSON.stringify(state,null,2))}const resolveFile=(baseDir,p)=>path.resolve(baseDir,String(p));async function reviewReuse(spec,baseDir){try{return await reuse.reviewSpec(spec,file=>fs.readFileSync(resolveFile(baseDir,file),"utf8"))}catch(e){return{mode:spec.reuse??"suggest",skipped:core.LoginRequiredError&&e instanceof core.LoginRequiredError?"未登录,无法查重":`查重失败:${String(e.message).slice(0,200)}`,datasets:{},cards:{}}}}function reuseHint(review){const ds=Object.entries(review.datasets||{}).filter(([,v])=>v.identical.length||v.similar.length),cards=Object.entries(review.cards||{});return!ds.length&&!cards.length?null:`发现可复用对象:数据集 ${ds.map(([k])=>k).join("、")||"无"},卡片 ${cards.map(([k])=>k).join("、")||"无"}。`+(review.mode==="auto"?"完全一样的将直接复用;相似的请比较后决定。":"完全一样的可在 spec 加 reuse: auto 直接复用;相似的请比较 columns 后决定复用、在原数据集上调整,还是新建。")}async function runBuild({spec,specFile,baseDir},{execute=!1,confirm=!1,style=!0,beforeWrite}={}){const errors=validateSpec(spec),plan=errors.length?{blocks:[],errors:[]}:planLayout(spec);errors.push(...plan.errors);const formal=errors.length?[]:formalNames(spec);if(errors.length)return oplog.emit("build.stage",{stage:"validate",ok:!1,errors:errors.length}),{ok:!1,stage:"validate",errors};const summary={name:spec.name,tenant:currentTenantLabel(),steps:describePlan(spec),layout:plan.blocks.map(b=>({key:b.key,x:b.x,y:b.y,w:b.width,h:b.height}))};if(!execute){const review2=await reviewReuse(spec,baseDir),hint=reuseHint(review2);return{ok:!0,mode:"dry-run",...summary,formalNames:formal,reuse:review2,next:[hint,formal.length?"含正式名:取得用户授权后加 --execute --confirm":"确认无误后加 --execute"].filter(Boolean).join(" ")}}if(formal.length&&!confirm)return{ok:!1,stage:"authorize",errors:[`以下为正式名(非 zz- 前缀),按 SOP 需要用户明确授权后加 --confirm:${formal.join("、")}`]};beforeWrite&&await beforeWrite();const review=spec.reuse==="auto"?await reviewReuse(spec,baseDir):{mode:spec.reuse??"suggest",datasets:{},cards:{}},stateFile=`${specFile}.state.json`,state=readState(stateFile),save=()=>writeState(stateFile,state),log=[];let stepAt=Date.now();log.push=(...lines)=>{for(const line of lines){const[stage,rest=""]=String(line).split(/\s+(.*)/s);oplog.emit("build.stage",{stage,detail:rest.slice(0,200),ok:!0,ms:Date.now()-stepAt}),stepAt=Date.now()}return Array.prototype.push.apply(log,lines)},oplog.emit("build.stage",{stage:"start",ok:!0,resumed:!!(state.planId||Object.keys(state.datasets||{}).length),datasets:(spec.datasets||[]).length,cards:(spec.cards||[]).length,reports:(spec.publish?.reports||[]).length});for(const d of spec.datasets||[]){if(state.datasets[d.key])continue;const reused=!d.id&&review.datasets[d.key]?.reuseId;state.datasets[d.key]=d.id||reused||await core.dsSave({name:d.name,sql:fs.readFileSync(resolveFile(baseDir,d.sql),"utf8")}),save(),log.push(`dataset ${d.key} → ${state.datasets[d.key]}${reused?"(复用已有数据集)":""}`)}for(const c of spec.cards||[]){if(!state.cards[c.key]){const dsId=state.datasets[c.dataset];let r;c.type==="existing"?r={cardId:c.id}:review.cards[c.key]?.reuseId?r={cardId:review.cards[c.key].reuseId,reused:!0}:c.type==="chart"?r=await core.cardChart({dsId,name:c.name,kind:c.kind,dim:list(c.dim).join(","),meas:list(c.meas).join(","),series:c.series}):c.type==="meter"?r=await core.cardMeter({dsId,name:c.name,col:c.col,subtitle:c.subtitle,color:c.color||void 0}):c.type==="table"?r=await core.cardTable({dsId,spec:c.spec}):r=await core.cardGantt({dsId,spec:c.spec}),state.cards[c.key]=r.cardId,save(),log.push(`card ${c.key} → ${r.cardId}${r.reused?"(复用已有卡片)":""}`)}const sharedCard=review.cards[c.key]?.reuseId&&state.cards[c.key]===review.cards[c.key].reuseId;if(c.sort&&!state.sorted[c.key]&&!sharedCard){const r=await core.cardSort(state.cards[c.key],String(c.sort));state.sorted[c.key]=r.sqlHasOrderBy,save(),log.push(`sort ${c.key} → sqlHasOrderBy=${r.sqlHasOrderBy}`)}}state.planId||(state.planId=await core.post("/page-setting/new-create-empty-plan",{name:spec.name,isPrivate:!1,pageType:0}),save(),log.push(`plan → ${state.planId}`));for(const b of plan.blocks){if(state.blocks[b.key])continue;const pos={x:b.x,y:b.y,width:b.width,height:b.height,mobileX:b.mobileX,mobileY:b.mobileY,mobileWidth:b.mobileWidth,mobileHeight:b.mobileHeight},body=b.kind==="title"?{...pos,sourceType:0,cardType:5,contentText:titleContent(b.title),bindFilterIds:[],userPageSettingId:state.planId}:{...pos,indexBlockId:state.cards[b.key],sourceType:0,cardType:1,bindFilterIds:[],userPageSettingId:state.planId};state.blocks[b.key]=(await core.post("/page-setting/new-page-card",body)).id,save()}if(log.push(`placed ${plan.blocks.length} blocks`),style&&!state.styled&&(state.styled=await applyStyle(spec,plan,state),save(),log.push(`style → ${JSON.stringify(state.styled)}`)),spec.filters&&!state.filtered){const text=fillPlaceholders(fs.readFileSync(resolveFile(baseDir,spec.filters),"utf8"),state,(await core.ctx()).dataSourceId),body=JSON.parse(text);state.filterDatasetIds=[...new Set((body.filters||[]).map(f=>f.indexBlockDataId).filter(id=>id&&id!==core.Z))],state.filtered=await core.planFilter(state.planId,body),save(),log.push(`filters → ${state.filtered.filters?.length??0}`)}for(const r of spec.publish?.reports||[])state.reports[r.card]||(await core.post("/index-block/operate-report",{indexBlockId:state.cards[r.card],name:r.name,tag:"",isPrivate:!1}),state.reports[r.card]=r.name,save(),log.push(`report ${r.name}`));const reusedCards=new Set(Object.entries(review.cards||{}).filter(([k,v])=>v.reuseId&&state.cards[k]===v.reuseId).map(([k])=>k)),verify=await verifyBuild(spec,plan,state,reusedCards);return oplog.emit("build.stage",{stage:"verify",ok:!!verify.ok,checks:Array.isArray(verify.checks)?verify.checks.length:void 0,failed:Array.isArray(verify.checks)?verify.checks.filter(c=>c&&c.ok===!1).length:void 0}),verify.ok?{ok:!0,mode:"execute",...await publishPlanKnowledge({planId:state.planId,operation:"create",authoring:knowledgeAuthoringFromBuild(spec,state)}),verify,log,stateFile,...review.mode==="auto"?{reuse:review}:{}}:{ok:!1,mode:"execute",verify,log,stateFile}}async function applyStyle(spec,plan,state){const detail=await core.planDetail(state.planId,!0),cards=detail.pageCards||[],expected=new Set(Object.values(state.blocks)),got=new Set(cards.map(c=>c.id));if(![...expected].every(id=>got.has(id)))return{skipped:"方案回读缺卡,未写样式(避免 update-page-card 移除卡片)"};if(!detail.name)return{skipped:"方案详情缺 name 字段,未写样式(避免覆盖方案配置)"};const keyOf=Object.fromEntries(Object.entries(state.blocks).map(([k,id])=>[id,k])),typeOf=Object.fromEntries((spec.cards||[]).map(c=>[c.key,c.type])),hasTitles=plan.blocks.some(b=>b.kind==="title"),parse=s=>{try{return typeof s=="string"?JSON.parse(s||"{}"):s||{}}catch{return{}}},items=cards.map(c=>{const key=keyOf[c.id],ds=parse(c.dataScreenSetting);if(key){const isTitle=key.startsWith("title-"),transparent=isTitle||TRANSPARENT_TYPES.has(typeOf[key]);Object.assign(ds,{useCardConfig:!0},transparent?{backgroundColor:"transparent"}:{},isTitle||hasTitles?{cardTitle:{...ds.cardTitle||{},show:!1}}:{})}return{id:c.id,x:c.x,y:c.y,width:c.width,height:c.height,mobileX:c.mobileX,mobileY:c.mobileY,mobileWidth:c.mobileWidth,mobileHeight:c.mobileHeight,zIndex:c.zIndex??0,dataScreenSetting:JSON.stringify(ds),...c.dataScreenChartSetting!==void 0?{dataScreenChartSetting:c.dataScreenChartSetting}:{}}}),page={...parse(detail.dataScreenSetting),pageBackgroundColor:plan.layout.pageBackgroundColor};return await core.post("/page-setting/update-page-card",{list:items,userPageSettingId:state.planId,dataScreenSetting:JSON.stringify(page),name:detail.name,tag:detail.tag||"",remark:detail.remark||"",parentId:detail.parentId,isPrivate:!!detail.isPrivate}),{cards:items.length,pageBackgroundColor:page.pageBackgroundColor}}async function verifyBuild(spec,plan,state,reusedCards=new Set){const checks=[];for(const c of spec.cards||[]){const q=await core.queryCard({cardId:state.cards[c.key],pageSize:1});checks.push({check:`query ${c.key}`,ok:q.code===200,detail:q.code===200?`total=${q.total}`:q.message})}for(const c of(spec.cards||[]).filter(x=>x.type==="table"&&x.spec?.fitTableWidth!==!1)){const d=await core.cardDetail(state.cards[c.key]),fit=(typeof d.chartSetting=="string"?(()=>{try{return JSON.parse(d.chartSetting||"{}")}catch{return{}}})():d.chartSetting||{}).config?.fitTableWidth===!0,shared=reusedCards.has(c.key);checks.push({check:`AG 自适应宽度 ${c.key}`,ok:fit||shared,detail:fit?"":shared?"复用的共享表格未开启,未修改":"fitTableWidth 未开启"})}const detail=await core.planDetail(state.planId,!0),cards=detail.pageCards||[],ids=new Set(cards.map(c=>c.id));checks.push({check:"方案卡片齐全",ok:Object.values(state.blocks).every(id=>ids.has(id)),detail:`${cards.length} 张`});const L=plan.layout,outOfBounds=cards.filter(c=>c.x<L.contentX||c.x+c.width>L.contentX+L.contentWidth);checks.push({check:`PC 卡位在 [${L.contentX},${L.contentX+L.contentWidth}] 内`,ok:!outOfBounds.length,detail:outOfBounds.map(c=>c.id).join(",")});const rects=cards.map(c=>({key:c.id,x:c.x,y:c.y,width:c.width,height:c.height,mobileX:c.mobileX,mobileY:c.mobileY,mobileWidth:c.mobileWidth,mobileHeight:c.mobileHeight})),ov=[...overlaps(rects,"pc"),...overlaps(rects,"mobile")];if(checks.push({check:"PC/手机无重叠",ok:!ov.length,detail:ov.join("; ")}),state.styled&&!state.styled.skipped){let page={};try{page=JSON.parse(detail.dataScreenSetting||"{}")}catch{}checks.push({check:"页面背景色",ok:page.pageBackgroundColor===L.pageBackgroundColor,detail:page.pageBackgroundColor||"(未设置)"})}for(const[k,v]of Object.entries(state.sorted))checks.push({check:`排序生效 ${k}`,ok:v===!0,detail:""});return{ok:checks.every(c=>c.ok),checks}}export{describePlan,fillPlaceholders,formalNames,loadSpec,planLayout,runBuild,titleContent,validateSpec,verifyBuild};
@@ -0,0 +1 @@
1
+ const NO_DATA_SETTING_CHARTS=new Set(["editor","iframe","clock","countdown"]),FRONTEND_DISABLED_CHARTS=new Set(["meter/commonMeter","meter/antdTable"]),PAGINATION_CHARTS=new Set(["agtable","gantt","multiFlowStep"]),FILTER_TYPE={select:1,time:2,condition:3},DEFAULT_TYPE={fixed:1,dynamic:4},parse=v=>{if(v&&typeof v=="object")return v;if(typeof v!="string"||!v.trim())return null;try{return JSON.parse(v)}catch{return}},filled=v=>!(v==null||v===""||Array.isArray(v)&&!v.length);function aliasError(title){if(!filled(title))return null;const s=String(title);return/^[0-9]/.test(s)?"不能以数字开头":/[~!@#$%^&*()+.`\-={}|;'",<>/?[\]]/.test(s)?"不能含特殊符号(可用中文括号)":null}const isCardSave=(method,p)=>String(method).toUpperCase()==="POST"&&/(?:^|\/)index-block\/(?:new|update-new)(?:\?|$)/i.test(String(p)),defaultDisabled=(parentId,id)=>FRONTEND_DISABLED_CHARTS.has(`${parentId}/${id}`);function cardErrors(body,{isDisabled=defaultDisabled}={}){if(!body||typeof body!="object"||Array.isArray(body))return[];const errors=[],chart=parse(body.chartSetting);if(!chart||!chart.id)return["请选择图表配置(chartSetting 缺失或不是合法 JSON,或没有图表 id)"];isDisabled(chart.parentId,chart.id)&&errors.push(`当前图表已禁用(${chart.parentId}/${chart.id}),请换用其它图表`);const dtos=Array.isArray(body.indexBlockDataSettingDtos)?body.indexBlockDataSettingDtos:[],drawn=dtos.filter(d=>[1,2,3].includes(Number(d?.argumentType)));!NO_DATA_SETTING_CHARTS.has(chart.id)&&!drawn.length&&errors.push("请选择绘制配置(维度 / 数值 / 系列至少一个字段)");for(const o of dtos.filter(d=>Number(d?.argumentType)===0))(!o?.title||!o?.orderByType)&&errors.push("指标排序不能为空(排序项要有字段 title 和排序方式 orderByType)");for(const d of drawn){const role=Number(d.argumentType);d.isAggregation&&d.isCustomize&&(role===2||role===3)&&errors.push(`聚合字段「${d.title||d.comment}」不可作为维度或系列`),d.isFill&&(role===1||role===3)&&errors.push(`填报字段「${d.title||d.comment}」不可作为数值或系列`)}for(const d of drawn)if(d.title&&d.comment&&d.title!==d.comment){const why=aliasError(d.title);why&&errors.push(`字段别名「${d.title}」${why}`)}if(chart.config?.report?.visible){const drawnIds=new Set(dtos.map(d=>d?.uniqueId).filter(Boolean));(Array.isArray(body.fillFields)?body.fillFields:[]).flatMap(b=>(b?.fields||[]).map(f=>f?.uniqueId)).filter(Boolean).some(id=>!drawnIds.has(id))&&errors.push("填报依据字段缺失,请重新选择(依据字段必须是卡片已绘制的字段)")}const fields=new Set(drawn.map(d=>d?.field).filter(Boolean));if((Array.isArray(body.filters)?body.filters:[]).forEach((f,i)=>{Number(f?.filterProperty)===1&&f?.propertyName&&!fields.has(f.propertyName)&&errors.push(`筛选条件配置错误:第 ${i+1} 个筛选(${f.labelName||f.propertyName})的结果集字段 ${f.propertyName} 不在卡片绘制字段里`),errors.push(...filterErrors(f,i,"card"))}),chart.id==="s2table"&&chart.config?.showPagination!==!0){const n=Number(body.limitCount);(!Number.isInteger(n)||n<1||n>5e3)&&errors.push("请输入数据条数,数据格式为整数,最大不能超过 5000(多维表未开分页)")}return[...new Set(errors)]}function normalizeCard(body){const chart=parse(body.chartSetting),notes=[];return PAGINATION_CHARTS.has(chart.id)&&chart.config?.showPagination===!0&&body.limitCount&&(delete body.limitCount,notes.push("已开启分页功能,数据条数已自动清空")),body.chartParentType=chart.parentId,body.chartType=chart.id,body.indexBlockType=chart.parentId==="table"?0:1,notes}function checkCardPayload(method,p,body,{isDisabled=defaultDisabled,previous=null}={}){if(!isCardSave(method,p)||!body||typeof body!="object"||Array.isArray(body))return[];const errors=cardErrors(body,{isDisabled}),before=previous?new Set(cardErrors(previous,{isDisabled})):new Set,introduced=errors.filter(e=>!before.has(e));if(introduced.length){const error=new Error(`卡片保存前校验未通过:${introduced.join(";")}`);throw error.errors=introduced,error}const kept=errors.filter(e=>before.has(e));return[...normalizeCard(body),...kept.length?[`卡片原有配置与界面保存规则不一致(本次未改动,未拦截,建议修正):${kept.join(";")}`]:[]]}function filterErrors(f,i=0,scope="plan"){const errors=[],name=`第 ${i+1} 个筛选(${f?.labelName||f?.fieldName||f?.propertyName||"未命名"})`;if(!f||typeof f!="object")return[`${name}不是对象`];const type=Number(f.filterType),property=Number(f.filterProperty);if(filled(f.filterOperationType)||errors.push(`${name}缺少类型选择 filterOperationType`),property===2){const ids=parse(f.variableIds);(Array.isArray(ids)?ids.filter(Boolean).length:filled(f.variableIds))||errors.push(`${name}是参数变量筛选,必须选择参数 variableIds`)}scope==="card"&&(property===1&&!filled(f.propertyName)&&errors.push(`${name}是结果集过滤,必须选择筛选维度 propertyName`),property===0&&!filled(f.fieldName)&&errors.push(`${name}是维度过滤,必须选择字段 fieldName`)),scope==="plan"&&property===1&&(Array.isArray(f.bindUserPageCards)?f.bindUserPageCards:[]).some(b=>!b?.pageCardId)&&errors.push(`${name}绑定的卡片缺少 pageCardId`);const valueType=Number(f.defaultValueType),value=parse(f.defaultValue)??f.defaultValue;return(type===FILTER_TYPE.select||type===FILTER_TYPE.time)&&!valueType&&errors.push(`${name}缺少默认值类型 defaultValueType`),type===FILTER_TYPE.time&&valueType===DEFAULT_TYPE.dynamic&&(filled(value)?Array.isArray(value)&&value.filter(v=>filled(v)).length!==2&&errors.push(`${name}动态日期区间要设置开始和结束两个值`):errors.push(`${name}动态日期默认值未设置`)),f.isRequired&&!filled(value)&&errors.push(`${name}是必填筛选,请设置默认值`),type===FILTER_TYPE.select&&(String(f.isSelfadd)==="true"?(f.userPageCardFilterValueRes||[]).filter(o=>filled(o?.value)).length||errors.push(`${name}手工选项为空,请添加选项`):filled(f.fieldName)||errors.push(`${name}选项来源缺少字段 fieldName`)),errors}const isFilterSave=(method,p)=>String(method).toUpperCase()==="POST"&&/(?:^|\/)page-setting\/batch-add-or-update-filter(?:\?|$)/i.test(String(p));function checkFilterPayload(method,p,body,{previous=null}={}){if(!isFilterSave(method,p)||!body||typeof body!="object")return[];const scope=Number(body.filterSourceType)===2?"card":"plan",keyed=list=>(Array.isArray(list)?list:[]).flatMap((f,i)=>filterErrors(f,i,scope).map(message=>({message,key:`${f?.labelName??i}|${message.replace(/^第 \d+ 个筛选/,"")}`}))),errors=keyed(body.filters),before=new Set(keyed(previous).map(e=>e.key)),introduced=errors.filter(e=>!before.has(e.key));if(introduced.length){const error=new Error(`筛选条件保存前校验未通过:${introduced.map(e=>e.message).join(";")}`);throw error.errors=introduced.map(e=>e.message),error}const kept=errors.filter(e=>before.has(e.key));return kept.length?[`筛选条件原有配置与界面保存规则不一致(本次未改动,未拦截,建议修正):${kept.map(e=>e.message).join(";")}`]:[]}export{FRONTEND_DISABLED_CHARTS,NO_DATA_SETTING_CHARTS,aliasError,cardErrors,checkCardPayload,checkFilterPayload,filterErrors,isCardSave,isFilterSave};
@@ -0,0 +1 @@
1
+ import fs from"node:fs";import path from"node:path";import os from"node:os";import crypto from"node:crypto";const DEFAULT_TTL_SECONDS=900,MAX_TTL_SECONDS=7200,MAX_NOTES=500,KINDS=new Set(["plan","card","ds","knowledge","spec","report","filter"]);class CollabConflictError extends Error{constructor(message,holders){super(message),this.name="CollabConflictError",this.holders=holders}}function collabDir(){return path.join(os.homedir(),".linkai","collab")}const stateFile=()=>path.join(collabDir(),"state.json"),lockFile=()=>path.join(collabDir(),"state.lock");function currentWorker(){return String(process.env.LINKAI_COLLAB_WORKER||"").trim()}function emptyState(){return{boards:{},claims:{}}}function readState(){try{return{...emptyState(),...JSON.parse(fs.readFileSync(stateFile(),"utf8"))}}catch{return emptyState()}}function sleepSync(ms){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms)}function update(fn){fs.mkdirSync(collabDir(),{recursive:!0,mode:448});const deadline=Date.now()+3e3;let fd=null,staleRemovalFailed=!1;for(;fd===null;){if(Date.now()>deadline)throw new Error("协同状态被占用超过 3 秒,请稍后重试");try{fd=fs.openSync(lockFile(),"wx",384)}catch(e){if(e.code!=="EEXIST")throw e;let stale=!1;if(!staleRemovalFailed)try{stale=Date.now()-fs.statSync(lockFile()).mtimeMs>5e3}catch{}if(stale)try{fs.rmSync(lockFile(),{force:!0});continue}catch{staleRemovalFailed=!0}sleepSync(10)}}try{const state=readState();pruneExpired(state);const result=fn(state),tmp=`${stateFile()}.${process.pid}.tmp`;return fs.writeFileSync(tmp,JSON.stringify(state,null,2),{mode:384}),fs.renameSync(tmp,stateFile()),result}finally{fs.closeSync(fd),fs.rmSync(lockFile(),{force:!0})}}function pruneExpired(state,now=Date.now()){for(const[key,claim2]of Object.entries(state.claims))claim2.expiresAt<=now&&delete state.claims[key]}const iso=ms=>new Date(ms).toISOString();function normalizeObject(raw){const text=String(raw||"").trim(),m=text.match(/^([a-z]+):(.+)$/i);if(!m||!KINDS.has(m[1].toLowerCase()))throw new Error(`对象写法不对:${text||"(空)"}。格式 <类型>:<id>,类型:${[...KINDS].join(" / ")},如 card:<cardId>`);const kind=m[1].toLowerCase(),id=kind==="spec"?path.resolve(m[2].trim()):m[2].trim().toLowerCase();return`${kind}:${id}`}function parseObjects(list){const items=(Array.isArray(list)?list:String(list||"").split(/[,,\s]+/)).filter(Boolean);if(!items.length)throw new Error("至少指定一个对象,如 --object card:<cardId>");return[...new Set(items.map(normalizeObject))]}function requireWorker(worker){const w=String(worker||"").trim();if(!w||w.length>40||!/^[\w一-龥.-]+$/.test(w))throw new Error("需要执行者名称:加 --worker <名称>(字母、数字、中文、. _ -,最长 40),如 --worker 主控 或 --worker sql-1");return w}function requireBoard(state,board){const b=state.boards[String(board||"")];if(!b)throw new Error(`任务板不存在:${board||"(空)"};先 linkai collab-start 创建,或 linkai collab-status 查看已有任务板`);return b}function addNote(board,worker,kind,text,ref){board.notes.push({at:iso(Date.now()),worker,kind,text:String(text).slice(0,2e3),...ref?{ref:String(ref).slice(0,500)}:{}}),board.notes.length>MAX_NOTES&&board.notes.splice(0,board.notes.length-MAX_NOTES)}function startBoard({goal,plan,worker}){const w=requireWorker(worker);if(!String(goal||"").trim())throw new Error("需要 --goal(这次协同要完成什么)");return update(state=>{const id=`b-${crypto.randomBytes(4).toString("hex")}`;return state.boards[id]={id,goal:String(goal).trim().slice(0,500),plan:plan?String(plan).toLowerCase():null,coordinator:w,createdAt:iso(Date.now()),closedAt:null,tasks:[],notes:[]},addNote(state.boards[id],w,"start",`创建任务板:${state.boards[id].goal}`),state.boards[id]})}function closeBoard({board,worker}){const w=requireWorker(worker);return update(state=>{const b=requireBoard(state,board),released=Object.entries(state.claims).filter(([,c])=>c.board===b.id).map(([k])=>k);for(const k of released)delete state.claims[k];return b.closedAt=iso(Date.now()),addNote(b,w,"close",`关闭任务板,释放 ${released.length} 个认领`),{board:b.id,closedAt:b.closedAt,released}})}function addTask({board,worker,title,assignee,objects}){const w=requireWorker(worker);if(!String(title||"").trim())throw new Error("需要 --title");return update(state=>{const b=requireBoard(state,board),task={id:b.tasks.length+1,title:String(title).trim().slice(0,300),assignee:assignee?requireWorker(assignee):null,objects:objects?parseObjects(objects):[],status:"todo",result:null,by:w,updatedAt:iso(Date.now())};return b.tasks.push(task),task})}const STATUSES=new Set(["todo","doing","done","blocked"]);function updateTask({board,worker,id,status:status2,result,assignee}){const w=requireWorker(worker);return update(state=>{const b=requireBoard(state,board),task=b.tasks.find(t=>t.id===Number(id));if(!task)throw new Error(`任务不存在:#${id}`);if(status2){if(!STATUSES.has(status2))throw new Error(`status 只能是 ${[...STATUSES].join(" / ")}`);task.status=status2}return assignee?task.assignee=requireWorker(assignee):status2==="doing"&&!task.assignee&&(task.assignee=w),result&&(task.result=String(result).slice(0,2e3)),task.updatedAt=iso(Date.now()),(status2==="done"||status2==="blocked")&&addNote(b,w,status2==="done"?"result":"blocker",`#${task.id} ${task.title}${result?`:${result}`:""}`),task})}function claim({board,worker,objects,ttl=DEFAULT_TTL_SECONDS,note:note2}){const w=requireWorker(worker),keys=parseObjects(objects),seconds=Math.min(Math.max(Number(ttl)||DEFAULT_TTL_SECONDS,60),MAX_TTL_SECONDS);return update(state=>{const b=requireBoard(state,board);if(b.closedAt)throw new Error(`任务板 ${b.id} 已关闭`);const now=Date.now(),held=keys.map(k=>[k,state.claims[k]]).filter(([,c])=>c&&c.worker!==w);if(held.length)throw new CollabConflictError(`对象已被其他执行者认领:${held.map(([k,c])=>`${k}(${c.worker},${Math.ceil((c.expiresAt-now)/6e4)} 分钟后过期)`).join(";")}`,held.map(([k,c])=>({object:k,...c,expiresAt:iso(c.expiresAt)})));for(const k of keys)state.claims[k]={worker:w,board:b.id,since:state.claims[k]?.since??now,expiresAt:now+seconds*1e3,note:note2?String(note2).slice(0,200):null};return addNote(b,w,"claim",`认领 ${keys.join("、")}${note2?`:${note2}`:""}`),{board:b.id,worker:w,objects:keys,expiresAt:iso(now+seconds*1e3)}})}function release({board,worker,objects}){const w=requireWorker(worker);return update(state=>{const b=requireBoard(state,board),wanted=objects?parseObjects(objects):Object.keys(state.claims).filter(k=>state.claims[k].board===b.id&&state.claims[k].worker===w),released=[];for(const k of wanted){const c=state.claims[k];if(c){if(c.worker!==w)throw new CollabConflictError(`不能释放别人的认领:${k}(${c.worker})`,[{object:k,...c,expiresAt:iso(c.expiresAt)}]);delete state.claims[k],released.push(k)}}return released.length&&addNote(b,w,"release",`释放 ${released.join("、")}`),{board:b.id,worker:w,released}})}function note({board,worker,text,kind="finding",ref}){const w=requireWorker(worker);if(!String(text||"").trim())throw new Error("需要 --text");const k=String(kind);if(!["finding","result","handoff","blocker","decision"].includes(k))throw new Error("kind 只能是 finding / result / handoff / blocker / decision");return update(state=>{const b=requireBoard(state,board);return addNote(b,w,k,text,ref),b.notes[b.notes.length-1]})}function status({board,notes=30}={}){const state=readState();pruneExpired(state);const now=Date.now(),claims=Object.entries(state.claims).map(([object,c])=>({object,worker:c.worker,board:c.board,note:c.note,expiresAt:iso(c.expiresAt),minutesLeft:Math.ceil((c.expiresAt-now)/6e4)}));if(!board)return{boards:Object.values(state.boards).filter(b2=>!b2.closedAt).map(b2=>({id:b2.id,goal:b2.goal,plan:b2.plan,coordinator:b2.coordinator,createdAt:b2.createdAt,tasks:b2.tasks.length,open:b2.tasks.filter(t=>t.status!=="done").length})),claims};const b=requireBoard(state,board);return{...b,notes:b.notes.slice(-Math.max(1,Number(notes)||30)),claims:claims.filter(c=>c.board===b.id)}}function assertWritable(objects,worker=currentWorker()){if(!objects.length||!fs.existsSync(stateFile()))return;const state=readState();pruneExpired(state);const now=Date.now(),held=objects.map(k=>[k,state.claims[k]]).filter(([,c])=>c&&c.worker!==worker);if(held.length)throw new CollabConflictError(`${held.map(([k,c])=>`${k} 正由「${c.worker}」处理(任务板 ${c.board},${Math.ceil((c.expiresAt-now)/6e4)} 分钟后过期)`).join(";")},${worker?`你是「${worker}」`:"当前命令没有 --worker"},已拒绝写入`,held.map(([k,c])=>({object:k,...c,expiresAt:iso(c.expiresAt)})))}function objectsForCommand(name,args,readJsonFile){const keys=[],add=(kind,id)=>{id&&keys.push(normalizeObject(`${kind}:${id}`))};if(add("card",args.card),add("ds",args.ds),args.plan&&(add("plan",args.plan),name==="plan-knowledge"&&add("knowledge",args.plan)),args.spec&&name==="build"&&add("spec",args.spec),name==="card-save"&&args.file)try{add("card",readJsonFile(args.file)?.id)}catch{}return[...new Set(keys)]}export{CollabConflictError,DEFAULT_TTL_SECONDS,addTask,assertWritable,claim,closeBoard,collabDir,currentWorker,normalizeObject,note,objectsForCommand,parseObjects,release,startBoard,status,updateTask};
@@ -0,0 +1,4 @@
1
+ import fs from"node:fs";import path from"node:path";import os from"node:os";import{fileURLToPath}from"node:url";import{cli,Strategy}from"@linkaicli/linkai-cli/registry";import{ArgumentError,AuthRequiredError,CliError}from"@linkaicli/linkai-cli/errors";import*as core from"./core.js";import*as build from"./build.js";import*as deliver from"./deliverable.js";import*as fillCard from"./fill-card.js";import*as collab from"./collab.js";import*as reuse from"./reuse.js";import*as dictionary from"./dictionary.js";import*as oplog from"@linkaicli/linkai-cli/oplog";const api={site:core.SITE_ID,browser:!1,defaultFormat:"json"},ui={site:core.SITE_ID,domain:"bi.linkmore.com",strategy:Strategy.UI,browser:!0,siteSession:"persistent",navigateBefore:!1,defaultFormat:"json"},str=(name,help,required=!0)=>({name,type:"string",required,help}),filter=(rows,search)=>search?rows.filter(r=>String(r.name).includes(search)):rows,json=file=>JSON.parse(core.readInputFile(file)),stamp=()=>new Date().toISOString().replace(/[-:]/g,"").replace("T","-").slice(0,15),outPath=(given,...fallback)=>given||core.artifactsDir(...fallback);function site(def){const func=def.func,guard=async run=>{try{return await run()}catch(e){throw e instanceof core.LoginRequiredError?new AuthRequiredError("bi.linkmore.com",e.message,"请用户执行 linkai login,在浏览器完成领谋授权"):e instanceof core.TenantMismatchError?new CliError("TENANT_MISMATCH",e.message,"先用 linkai login --tenant <租户名> 切换到方案规范指定的租户,再用 linkai status 确认;无法切换就停止并告诉用户"):e instanceof core.NetworkError?new CliError("NETWORK_ERROR",e.message,"网络暂时不通(已自动重试):等 1~2 分钟或请用户检查网络 / 代理后再执行;只读命令可直接重试,写命令先用只读命令确认结果再决定是否重做",1):(e instanceof core.ApiError||e instanceof core.KindError)&&e.kind?new CliError(e.kind,e.message,API_ERROR_HINTS[e.kind],1):e instanceof collab.CollabConflictError?new CliError("COLLAB_CONFLICT",e.message,"别的执行者正在改这个对象:先 linkai collab-status --board <任务板> 看分工,改做别的任务,或等对方释放;不要绕过",1):e}},checkCollab=a=>{def.access==="write"&&!def.name.startsWith("collab-")&&collab.assertWritable(collab.objectsForCommand(def.name,a||{},json))},wrapped=def.browser?async(page,a)=>guard(()=>(checkCollab(a),func(page,a))):async a=>guard(()=>(checkCollab(a),func(a)));return cli({...def,func:wrapped})}const API_ERROR_HINTS={SQL_INVALID:"SQL 里有视图中不存在的字段 / 表或语法错误:用 linkai kb-object / ds-tables 核对字段名后改 SQL,先 sql-check 通过再建或改数据集",IN_USE:"对象仍被引用:先用 linkai ds-refs --ds <数据集> 找到引用它的卡片,确认后先删卡片,再删数据集",NOT_FOUND:"对象不存在或已删除:用 plan-list / card-list / ds-list 重新确认精确 id,不要重复执行写命令",FORBIDDEN:"当前用户没有该对象的权限(如私有数据集):换用自己可见的对象,或请对象负责人共享",DUPLICATE_NAME:"同名对象已存在——很可能上一次已经创建成功:先用 card-list / ds-list / plan-list --search <名称> 确认并复用,不要换个名字或重复执行同样的创建",SQL_READONLY:"数据集只能是单条只读查询(SELECT 或 WITH … SELECT),不能改数据或建表;本 CLI 没有操作数据库的能力",SQL_TABLE_NOT_ALLOWED:"只能查询 linkai ds-tables 列出的租户视图:换用清单里的视图;确实需要新视图时停下,告诉用户联系领谋数据团队开放",READ_ONLY:"系统报表 / 系统方案(资源包下发到本租户的)只读:不能在本租户改,要到来源租户修改后重新下发;不要换个接口重复尝试,告诉用户",BAD_REQUEST:"请求体结构不对:先查 references/api-catalog.md,或回读一条现有数据照抄结构;优先用已封装的命令,不要反复试",KNOWLEDGE_INVALID:"方案知识文件不完整:按 message 补齐(每张线上业务卡片一段、cardId 必填、修改方案必须写 changeSummary),格式见 templates/report-spec/plan-knowledge.example.yaml",CONFIG_INVALID:"配置违反领谋界面的保存规则(与在界面上点保存时的校验一致):按 message 逐条修正后再保存;规则见知识库 references/card.md、filters.md",FILE_NOT_FOUND:"文件路径不对:确认文件已写出、路径相对当前目录;不要原样重复执行"},loginCommand=site({...api,name:"login",access:"write",description:"首次打开领谋 OAuth 授权页;同账号切换租户复用授权,切换账号需显式 --switch-account",args:[str("tenant","目标租户(名称、编码或 id);已有授权时无需再打开浏览器",!1),{name:"timeout",type:"int",default:300,help:"等待浏览器完成 OAuth 授权的秒数"},{name:"no-browser",type:"boolean",default:!1,help:"只打印授权地址,不自动打开系统浏览器"},{name:"switch-account",type:"boolean",default:!1,help:"用户确认切换领谋账号后,重新打开 OAuth 授权页"}],func:async args=>{const timeoutSeconds=Number(args.timeout||300);if(!Number.isInteger(timeoutSeconds)||timeoutSeconds<1||timeoutSeconds>1800)throw new ArgumentError("timeout 必须是 1-1800 秒");if(!args["switch-account"]){let current=null;try{current=await core.ensureSession()}catch(error){if(!(error instanceof core.LoginRequiredError))throw error}if(current){const result=args.tenant?await core.switchTenant(current,args.tenant):{session:current,switched:!1};return result.switched&&(dictionary.resetReads("switch-tenant"),core.clearShortCache()),{login:result.switched?"oauth_tenant_switch":"oauth_existing",autoRelogin:!0,...core.spaceOf(result.session)}}}dictionary.resetReads("login"),core.clearShortCache();const session=await core.oauthLogin({tenant:args.tenant,timeoutSeconds,...args["no-browser"]?{browser:async()=>!1}:{},switchAccount:args["switch-account"]});return{login:"oauth",autoRelogin:!0,...core.spaceOf(session)}}}),logoutCommand=cli({...api,name:"logout",access:"write",description:"退出登录:撤销 OAuth 会话并删除本机登录信息",args:[],func:async()=>{const revoked=await core.oauthLogout();return core.forgetCredentials(),core.clearSession(),{logout:!0,oauthRevoked:revoked}}}),doctorCommand=site({...api,name:"status",access:"read",description:"查看登录状态、当前租户、是否自动续登、数据源与可用表数量",args:[],func:async()=>{const session=await core.ensureSession(),p=core.tokenPayload(session.token),ctx=await core.ctx(),allow=await core.allowedTables();return{loggedIn:!0,...core.spaceOf(session),autoRelogin:!0,authMode:"oauth",tokenHoursLeft:p.exp?Math.round((p.exp*1e3-Date.now())/36e4)/10:null,dataSourceId:ctx.dataSourceId,dataSources:ctx.dataSources,tables:allow.size,...unfinishedFlowsHint()}}});function describeFlow(f){const next=f.status==="interrupted"&&f.reason==="build-incomplete"?"用同一个 spec 再执行 build --execute 会断点续跑;不再继续就清理试搭对象后 flow-end --status abandoned":f.status==="interrupted"&&f.reason==="outcome-unknown"?"上次写操作结果未知:先用只读命令回读确认现状,不要直接重复执行写命令":`继续做就在命令上加 --flow ${f.id};不再继续就 linkai flow-end --flow ${f.id} --status abandoned`;return{flow:f.id,goal:f.goal,type:f.type,plan:f.plan,source:f.source,status:f.status,reason:f.reason,lastActivity:new Date(f.lastActivity).toISOString(),lastCommand:f.lastCommand,lastErrorCode:f.lastErrorCode,build:f.build,next}}function unfinishedFlowsHint(){try{const list=oplog.unfinishedFlows().filter(f=>f.id!==oplog.currentFlow()?.id).slice(0,5).map(describeFlow);return list.length?{unfinishedFlows:list}:{}}catch{return{}}}const tenantsCommand=site({...api,name:"tenants",access:"read",description:"查看可用租户、当前租户及下一步;用户完成 OAuth 授权后执行",args:[],func:async()=>{const s=await core.ensureSession(),list=await core.tenantList(s),current=list.find(t=>t.tenantId===s.tenantId),tenants=list.map(t=>({name:t.name,tenantId:t.tenantId,current:t.tenantId===s.tenantId}));let next;return list.length===1?next=`只有一个租户,当前租户是「${list[0].name}」。`:next=`当前租户是「${current?.name||""}」。同账号可直接执行 linkai login --tenant <租户名> 切换,无需重新授权。`,{tenantCount:list.length,single:list.length===1,current:current?.name||core.spaceOf(s).tenantName,tenants,next}}}),kindsCommand=site({...api,name:"kinds",access:"read",defaultFormat:"table",description:"列出 74 种图表类型、字段建议与可用状态",args:[],func:async()=>core.kinds()}),sqlCheckCommand=site({...api,name:"sql-check",access:"read",defaultFormat:"table",description:"校验数据集 SQL(只读、可用表)并列出字段;多个文件用逗号分隔一次校验(省去多次启动)",args:[str("file","SQL 文件路径;多个用逗号分隔,如 sql/a.sql,sql/b.sql")],func:async args=>{const files=String(args.file).split(/[,,]/).map(f=>f.trim()).filter(Boolean),fields=list=>list.map(f=>({sort:f.sort_num,field:f.field_name,type:f.field_type,dbType:f.type_name})),dictNotice=async()=>{try{await checkDictionary({enforce:!1})}catch(e){if(e instanceof core.LoginRequiredError)throw e}};if(files.length===1){nudgeBatchSqlCheck();const sql=core.readInputFile(files[0]);return core.precheckSql(sql),await dictNotice(),fields(await core.checkSql(sql))}await dictNotice();const results=[];for(const file of files)try{results.push({file,ok:!0,fields:fields(await core.checkSql(core.readInputFile(file)))})}catch(e){if(e instanceof core.LoginRequiredError)throw e;results.push({file,ok:!1,error:String(e.message).slice(0,500)})}return{total:files.length,failed:results.filter(r=>!r.ok).length,results}}});function nudgeBatchSqlCheck(){try{const file=path.join(os.homedir(),".linkai","oplog","hints.json");let state={};try{state=JSON.parse(fs.readFileSync(file,"utf8"))||{}}catch{}const now=Date.now(),recent=[...(state.sqlCheck||[]).filter(t=>now-t<5*6e4),now];state.sqlCheck=recent.slice(-10),recent.length>=3&&now-(state.sqlCheckNoticeAt||0)>10*6e4&&(state.sqlCheckNoticeAt=now,oplog.addNotice("最近几分钟在逐个校验 SQL:可以一次校验多个文件,如 linkai sql-check --file sql/a.sql,sql/b.sql,sql/c.sql(每条命令都有启动开销,逐个跑更慢),结果按文件分别列出。","sql-check-batch")),fs.mkdirSync(path.dirname(file),{recursive:!0}),fs.writeFileSync(file,JSON.stringify(state),{mode:384})}catch{}}const fmtCommand=site({...api,name:"fmt",access:"read",defaultFormat:"plain",description:"把 SQL 格式化为易读的多行文本",args:[str("file","SQL 文件路径")],func:async args=>core.formatSql(core.readInputFile(args.file))}),dsListCommand=site({...api,name:"ds-list",access:"read",defaultFormat:"table",description:"列出当前租户的数据集",args:[str("search","按名称关键字过滤(可选)",!1)],func:async args=>filter(await core.dsList(),args.search)}),dsTablesCommand=site({...api,name:"ds-tables",access:"read",defaultFormat:"table",description:"列出数据集 SQL 可以使用的表(视图)",args:[str("search","按表名关键字过滤(可选)",!1)],func:async args=>[...await core.allowedTables()].sort().filter(n=>!args.search||n.includes(String(args.search).toLowerCase())).map(table=>({table}))}),dsFieldsCommand=site({...api,name:"ds-fields",access:"read",defaultFormat:"table",description:"列出数据集字段(列名就是卡片引用的映射键)",args:[str("ds","数据集的精确 id")],func:async args=>(await core.dsFields(args.ds)).map(x=>({sort:x.sortNum,column:x.tableFieldComment,type:x.tableFieldType,argumentType:x.argumentType}))}),dsSqlCommand=site({...api,name:"ds-sql",access:"read",description:"把数据集当前保存的 SQL 读出到文件",args:[str("ds","数据集的精确 id"),str("output","输出 SQL 路径(默认 ~/.linkai/artifacts/linkai/sql/<dsId>.sql)",!1)],func:async args=>{const d=await core.dsDetail(args.ds),output=core.writeJsonFile(outPath(args.output,"sql",`${args.ds}.sql`),d.sql||"");return{name:d.name,lines:(d.sql||"").split(`
2
+ `).length,output}}}),dsRefsCommand=site({...api,name:"ds-refs",access:"read",defaultFormat:"table",description:"列出引用该数据集的卡片",args:[str("ds","数据集的精确 id")],func:async args=>core.dsRefs(args.ds)}),dsMatchCommand=site({...api,name:"ds-match",access:"read",description:"复用查重:在当前租户里找与这段 SQL 完全一样或相似的数据集(建数据集前先查,不含 zz- 试搭对象)",args:[str("file","SQL 文件路径"),{name:"top",type:"int",default:3,help:"相似候选最多几个"}],func:async args=>{const m=reuse.matchDataset(core.readInputFile(args.file),await reuse.datasetIndex(),{top:Number(args.top)||3});return{...m,next:m.identical.length?`有完全一样的数据集,直接复用 ${m.identical[0].dsId}(build spec 的 datasets 写 id,或 spec 加 reuse: auto)`:m.similar.length?"有相似数据集:比较 columns 与口径后决定复用、在原数据集上调整(先 ds-refs 看影响),还是新建":"没有可复用的数据集,可以新建"}}}),dsCreateCommand=site({...api,name:"ds-create",access:"write",description:"新建 SQL 数据集(先做只读与可用表校验,并格式化 SQL);当前租户已有完全一样的数据集时拒绝,除非 --allow-duplicate",args:[str("name","数据集名称(租户内唯一;试验用 zz- 前缀)"),str("file","SQL 文件路径"),{name:"allow-duplicate",type:"boolean",default:!1,help:"已有完全一样的数据集仍要新建(需说明理由,如要独立演进口径)"}],func:async args=>{const sql=core.readInputFile(args.file);core.precheckSql(sql),await checkDictionary({enforce:!0});let match=null;try{match=reuse.matchDataset(sql,await reuse.datasetIndex())}catch(e){if(e instanceof core.LoginRequiredError)throw e}if(match?.identical.length&&!args["allow-duplicate"])throw new CliError("DUPLICATE_DATASET",`当前租户已有完全一样的数据集:${match.identical.map(d=>`「${d.name}」(${d.dsId})`).join("、")}`,"直接复用该数据集(build spec 的 datasets 写 id,或 spec 加 reuse: auto);确需独立一份才加 --allow-duplicate",1);const dsId=await core.dsSave({name:args.name,sql}),fields=await core.dsFields(dsId);return{dsId,name:args.name,fields:fields.map(f=>f.tableFieldComment),...match?.similar.length?{similar:match.similar,next:"已有相似数据集:确认口径确实不同;如可合并,后续改为复用"}:{}}}}),dsUpdateCommand=site({...api,name:"ds-update",access:"write",description:"原地更新数据集(id 不变,卡片映射不断)",args:[str("ds","数据集的精确 id"),str("name","数据集名称"),str("file","SQL 文件路径")],func:async args=>{const sql=core.readInputFile(args.file);core.precheckSql(sql);const current=await core.dsDetail(args.ds);if(current?.name===args.name&&current?.sql&&reuse.sameSql(sql,current.sql)){const fields2=await core.dsFields(args.ds);return{dsId:args.ds,sameId:!0,unchanged:!0,fields:fields2.map(f=>f.tableFieldComment),next:"SQL 和名称与线上一致,未重复保存;不要再次执行同样的更新"}}await checkDictionary({enforce:!0});const dsId=await core.dsSave({id:args.ds,name:args.name,sql,current}),fields=await core.dsFields(dsId);return{dsId,sameId:dsId===args.ds,fields:fields.map(f=>f.tableFieldComment)}}}),cardListCommand=site({...api,name:"card-list",access:"read",defaultFormat:"table",description:"列出当前租户的卡片",args:[str("search","按名称关键字过滤(可选)",!1)],func:async args=>filter(await core.cardList(),args.search)}),cardGetCommand=site({...api,name:"card-get",access:"read",description:"把卡片完整配置保存为 JSON 文件(修改前的基线,或 card-save 的输入)",args:[str("card","卡片的精确 id"),str("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/cards/<cardId>.json)",!1)],func:async args=>{const d=await core.cardDetail(args.card),output=core.writeJsonFile(outPath(args.output,"cards",`${args.card}.json`),d);return{name:d.name,fields:(d.indexBlockDataSettingDtos||[]).length,output}}}),cardSaveCommand=site({...api,name:"card-save",access:"write",description:"保存完整的卡片配置 JSON(唯一能让配置生效的保存方式)",args:[str("file","修改后的卡片 JSON 文件路径")],func:async args=>{const body=json(args.file);if(!body.id)throw new Error("Card payload must contain id");const cs=typeof body.chartSetting=="string"?JSON.parse(body.chartSetting||"{}"):body.chartSetting||{};body.chartSetting=JSON.stringify(cs),body.chartType=body.chartType||cs.id,body.chartParentType=body.chartParentType||cs.parentId,await core.post("/index-block/update-new",body),String(body.chartType||cs.id)==="agtable"&&cs.config?.fitTableWidth!==!0&&oplog.addNotice("这张 AG 表格没有开启自适应宽度(chartSetting.config.fitTableWidth)。按 BI 操作规则应设为 true,列宽 agColumnWidths 只作为比例(如等分统一 50);用户明确要求固定宽度时除外。","ag-fit-width");const after=await core.cardDetail(body.id);return{cardId:body.id,name:after.name,fields:(after.indexBlockDataSettingDtos||[]).length}}}),fillWriteCommand=site({...api,name:"fill-write",access:"write",description:"写自由填报数据:一条记录一个元素、字段一次带齐(默认只校验并预览,加 --execute 才写入)",args:[str("card","填报卡片的精确 id(usedIndexBlockId)"),str("file",'记录 JSON 数组文件:[{"字段名":"值",…,"_id":"已有记录ID(可选)"}]'),str("key","填报块:fillfieldid(第一个块)、fillfieldid1(第二个块)…",!1),{name:"batch",type:"int",default:100,help:"每次请求的记录数(1-500)"},{name:"execute",type:"boolean",default:!1,help:"确认后真正写入"}],func:async args=>{const batch=Number(args.batch||100);if(!Number.isInteger(batch)||batch<1||batch>500)throw new ArgumentError("batch 必须是 1-500");await core.assertCardInTenant(args.card);const detail=await core.cardDetail(args.card);if(detail?.isFreeFill===!1)throw new Error("该卡片不是自由填报卡片;fill-write 只支持自由填报");const block=fillCard.fillBlockOf(detail,args.key||"fillfieldid"),fillValues=fillCard.buildFillValues(block,json(args.file)),batches=[];for(let i=0;i<fillValues.length;i+=batch)batches.push(fillValues.slice(i,i+batch));const plan={cardId:args.card,name:detail.name,key:block.key,fields:block.fields,uniqueFields:block.uniqueFields,records:fillValues.length,updates:fillValues.filter(x=>x.value).length,batches:batches.length,sample:fillValues.slice(0,2)};if(!args.execute)return{...plan,executed:!1,next:"核对字段与记录数后,用户确认再加 --execute"};const written=[];for(const[i,part]of batches.entries())try{await core.post("/index-block/new-fill-field",{usedIndexBlockId:args.card,fillValues:part}),written.push(part.length)}catch(error){throw new Error(`第 ${i+1}/${batches.length} 批写入失败(已成功 ${written.length} 批、${written.reduce((a,b)=>a+b,0)} 条):${error.message}。先回读填报表按依据字段核对,再只补未成功的部分,不要整批重跑`)}return{...plan,executed:!0,written:written.reduce((a,b)=>a+b,0),next:"回读填报表本身:行数、依据字段无空值无重复、抽查整行字段完整;再核对看板"}}}),cardChartCommand=site({...api,name:"card-chart",access:"write",description:"按图表类型新建或覆盖图表卡片(指定维度、数值、系列列)",args:[str("ds","数据集的精确 id"),str("name","卡片名称"),str("kind","图表类型,如 column/stacked(linkai kinds 查看全部)"),str("dim","维度列名,多个用逗号分隔",!1),str("meas","数值列名,多个用逗号分隔"),str("series","系列列名(可选)",!1),str("card","要覆盖的已有卡片 id(可选)",!1)],func:async args=>core.cardChart({dsId:args.ds,name:args.name,kind:args.kind,dim:args.dim,meas:args.meas,series:args.series,id:args.card})}),cardMeterCommand=site({...api,name:"card-meter",access:"write",description:"新建或覆盖单数值指标卡(meter/list)",args:[str("ds","数据集的精确 id"),str("name","卡片名称"),str("col","数值列名"),str("subtitle","副标题",!1),str("color","数值颜色(十六进制,如 #E8611C)",!1),str("card","要覆盖的已有卡片 id(可选)",!1)],func:async args=>core.cardMeter({dsId:args.ds,name:args.name,col:args.col,subtitle:args.subtitle,color:args.color||void 0,id:args.card})}),cardTableCommand=site({...api,name:"card-table",access:"write",description:"按 spec JSON 新建或覆盖 AG 明细表(见 references/card.md)",args:[str("ds","数据集的精确 id"),str("spec","spec JSON 文件路径"),str("card","要覆盖的已有卡片 id(可选)",!1)],func:async args=>core.cardTable({dsId:args.ds,spec:json(args.spec),id:args.card})}),cardGanttCommand=site({...api,name:"card-gantt",access:"write",description:"按 spec JSON 新建或覆盖甘特图卡片(见 references/gantt.md)",args:[str("ds","数据集的精确 id"),str("spec","spec JSON 文件路径"),str("card","要覆盖的已有卡片 id(可选)",!1)],func:async args=>core.cardGantt({dsId:args.ds,spec:json(args.spec),id:args.card})}),cardSortCommand=site({...api,name:"card-sort",access:"write",description:'设置卡片持久排序("列:asc,列:desc"、.json 文件或 clear),并回读确认排序生效',args:[str("card","卡片的精确 id"),str("spec",'"waxis3:desc,ID:asc" | sort.json | clear')],func:async args=>{const spec=/\.json$/i.test(String(args.spec))?json(args.spec):args.spec;return core.cardSort(args.card,Array.isArray(spec)?spec:spec.sortField||spec)}}),queryCommand=site({...api,name:"query",access:"read",description:"按页面方式查询卡片数据;返回状态码、报错信息、行数和实际执行的 SQL",args:[str("card","卡片的精确 id"),{name:"page-size",type:"int",default:20,help:"取数行数"},str("filters","筛选条件 JSON 文件路径",!1),str("output","数据行保存路径(不在屏幕上输出数据)",!1)],func:async args=>{const r=await core.queryCard({cardId:args.card,pageSize:args["page-size"],filters:args.filters?json(args.filters):[]});return args.output&&core.writeJsonFile(args.output,r.data),delete r.data,r}}),planListCommand=site({...api,name:"plan-list",access:"read",defaultFormat:"table",description:"列出分析方案(看板)",args:[str("search","按名称关键字过滤(可选)",!1)],func:async args=>filter(await core.planList(),args.search)}),planGetCommand=site({...api,name:"plan-get",access:"read",description:"把方案详情保存为 JSON 文件(含租户模板筛选器)",args:[str("plan","方案的精确 id"),str("output","输出 JSON 路径(默认 ~/.linkai/artifacts/linkai/plans/<planId>.json)",!1),{name:"personal",type:"boolean",default:!1,help:"读取自己的个人筛选器副本"}],func:async args=>{const d=await core.planDetail(args.plan,!args.personal),output=core.writeJsonFile(outPath(args.output,"plans",`${args.plan}.json`),d);return{planId:args.plan,cards:(d.pageCards||[]).map(c=>({pageCardId:c.id,name:c.indexBlock?.name})),output}}}),planCreateCommand=site({...api,name:"plan-create",access:"write",description:"新建空的分析方案(看板)",args:[str("name","方案名称")],func:async args=>{const planId=await core.post("/page-setting/new-create-empty-plan",{name:args.name,isPrivate:!1,pageType:0});return oplog.addNotice(`手工搭建方案:完成后必须用 linkai plan-knowledge --plan ${planId} --mode create --file <知识文件> 交付方案知识库(铁律 2)。新建看板优先用 linkai build(自动生成知识库并验收)。`,"plan-knowledge"),{planId,name:args.name}}}),planAddCommand=site({...api,name:"plan-add",access:"write",description:"把卡片放到方案的指定位置;返回 pageCardId(绑定筛选器时使用)",args:[str("plan","方案的精确 id"),str("card","卡片的精确 id"),{name:"x",type:"int",default:0,help:"横向位置(格,0–23)"},{name:"y",type:"int",default:0,help:"纵向位置(格)"},{name:"w",type:"int",default:24,help:"宽度(格,满宽 24)"},{name:"h",type:"int",default:20,help:"高度(格)"}],func:async args=>core.planAdd({planId:args.plan,cardId:args.card,x:args.x,y:args.y,w:args.w,h:args.h})}),planFilterCommand=site({...api,name:"plan-filter",access:"write",description:"保存方案筛选器为租户模板(全量覆盖),并重置自己的个人副本",args:[str("plan","方案的精确 id"),str("file","筛选器请求体 JSON 文件路径")],func:async args=>core.planFilter(args.plan,json(args.file))}),reportListCommand=site({...api,name:"report-list",access:"read",defaultFormat:"table",description:"列出数据报表(每个报表对应一张卡片)",args:[str("search","按名称关键字过滤(可选)",!1)],func:async args=>filter(await core.reportList(),args.search)}),reportCardCommand=site({...api,name:"report-card",access:"read",description:"查询卡片对应报表的 userPageCardId(重置报表筛选器时使用)",args:[str("card","卡片的精确 id")],func:async args=>{const d=await core.reportDetail(args.card);return{cardId:args.card,userPageCardId:d.id}}}),reportPublishCommand=site({...api,name:"report-publish",access:"write",description:"把卡片发布为数据报表",args:[str("card","卡片的精确 id"),str("name","报表名称")],func:async args=>{await core.post("/index-block/operate-report",{indexBlockId:args.card,name:args.name,tag:"",isPrivate:!1});const hit=(await core.reportList()).filter(r=>r.cardId===args.card);return{cardId:args.card,published:hit.length===1,url:`https://bi.linkmore.com/view/table?indexBlockId=${args.card}&status=view`}}}),resetFilterCommand=site({...api,name:"reset-filter",access:"write",description:"丢弃自己的个人筛选器副本,按模板重建(方案或报表卡片)",args:[str("plan","方案 id(方案筛选器)",!1),str("page-card","报表 userPageCardId 或方案 pageCardId(卡片筛选器)",!1)],func:async args=>{if(!!args.plan==!!args["page-card"])throw new Error("Pass exactly one of --plan or --page-card");const body=args.plan?{userPageSettingId:args.plan,filterSourceType:4}:{userPageCardId:args["page-card"],filterSourceType:2};return{filters:(await core.post("/page-setting/reset-user-filter",body)||[]).map(f=>f.labelName)}}}),PKG_ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),"..","..");try{core.setClientVersion(JSON.parse(fs.readFileSync(path.join(PKG_ROOT,"package.json"),"utf8")).version)}catch{}const KB_ROOTS={knowledge:path.join(PKG_ROOT,"knowledge"),templates:path.join(PKG_ROOT,"templates")};function walk(dir,base=dir){return fs.existsSync(dir)?fs.readdirSync(dir,{withFileTypes:!0}).flatMap(e=>{const p=path.join(dir,e.name);return e.isDirectory()?walk(p,base):e.name.startsWith(".")?[]:[path.relative(base,p)]}):[]}function readLocal(rootName,rel){const root=KB_ROOTS[rootName],target=path.resolve(root,rel);if(target!==root&&!target.startsWith(root+path.sep))throw new ArgumentError("路径必须在 knowledge/ 或 templates/ 之内");if(!fs.existsSync(target)||fs.statSync(target).isDirectory())throw new ArgumentError(`文件不存在:${rootName}/${rel}`,"执行 linkai kb 查看可用文件");return fs.readFileSync(target,"utf8")}function kbUnavailable(library){return new CliError("KB_UNAVAILABLE",`知识库服务暂不可用:${library}`,"稍后重试;持续出现请联系领谋",1)}function aiKbFailure(error){if(!(error instanceof core.AiKbError))return error;const codes={400:"AI_KB_BAD_REQUEST",403:"AI_KB_FORBIDDEN",404:"AI_KB_NOT_FOUND",409:"AI_KB_CONFLICT",503:"AI_KB_UNAVAILABLE"},hints={404:"确认 source、对象或文档属于当前租户;数据字典不会回退本地内容",409:"知识元数据未就绪时先联系知识库维护方;文档持续变化时稍后重试",503:"稍后重试;数据字典没有本地兜底,持续出现请联系领谋"};return new CliError(codes[error.code]||"AI_KB_ERROR",error.message,hints[error.code],1)}function intOption(value,fallback,name,min,max){const n=value==null||value===""?fallback:Number(value);if(!Number.isInteger(n)||n<min||n>max)throw new ArgumentError(`${name} 必须是 ${min}~${max} 的整数`);return n}async function requireDatasetSource(source){const context=await core.ctx();if(String(source).toLowerCase()!==String(context.dataSourceId).toLowerCase())throw new CliError("AI_KB_SOURCE_MISMATCH",`数据字典 source ${source} 与当前数据集 source ${context.dataSourceId} 不一致`,"不要跨 source 使用元数据;先确认服务端已为当前数据源完成探库,或确认 CLI 的数据源配置",1)}function localBiDocs(){return walk(path.join(KB_ROOTS.knowledge,"report-builder")).map(p=>({file:`knowledge/report-builder/${p}`,title:"",summary:"",library:"bi-builder",scope:"public",source:"bundled-fallback"}))}function localBiDoc(req){return{file:`knowledge/${req.canonicalPath}`,title:"",library:req.library,scope:req.scope,source:"bundled-fallback",content:readLocal("knowledge",req.canonicalPath)}}function logKb(op,result){const list=Array.isArray(result)?result:null,library=list?"bi-builder":result?.library||(String(result?.file||"").startsWith("templates/")?"templates":String(result?.file||"").includes("browser/")?"cli-docs":"bi-builder");return oplog.emit("kb.read",list?{library,op,docs:list.length,sources:[...new Set(list.map(x=>x?.source).filter(Boolean))]}:{library,op,file:result?.file??null,source:result?.source??null,chars:typeof result?.content=="string"?result.content.length:void 0}),result}function logDict(op,args,result,extra={}){return oplog.emit("kb.read",{library:"data-dictionary",op,source:args.source??null,...extra}),op!=="sources"&&args.source&&dictionary.recordRead({tenantId:core.readSession()?.tenantId,source:args.source,flowId:oplog.currentFlow()?.id??null,op}),result}const DICT_CACHE_MS=2*6e4;async function checkDictionary({enforce}){const session=await core.ensureSession(),c=await core.ctx(),st=await dictionary.dictionaryStatus({tenantId:session.tenantId,source:c.dataSourceId,flowId:oplog.currentFlow()?.id??null,fetchSources:()=>core.shortCache("dict:sources",DICT_CACHE_MS,async()=>(await core.aiKbSources()).sources||[]),fetchDocs:()=>core.shortCache(`dict:docs:${c.dataSourceId}`,DICT_CACHE_MS,async()=>((await core.aiKbDocs({source:c.dataSourceId,pageIndex:1,pageSize:20})).items||[]).map(d=>({updatedAt:d.updatedAt})))});if(core.logGuard("dictionary-fresh",st.ok,{tenantId:session.tenantId,source:c.dataSourceId,reason:st.reason??st.skipped??null,readAt:st.readAt??null,dictUpdatedAt:st.dictUpdatedAt??null,enforce}),st.skipped&&oplog.addNotice(`数据字典:${st.skipped}。`,"dictionary-skipped"),!st.ok){if(enforce)throw new CliError("DICTIONARY_REQUIRED",st.reason,dictionary.readHint(c.dataSourceId),1);oplog.addNotice(`数据字典:${st.reason}。正式写入(ds-create / ds-update / build --execute)前会被拒绝。${dictionary.readHint(c.dataSourceId)}`,"dictionary-stale")}return st}const top=(list,n,pick)=>Array.isArray(list)?list.slice(0,n).map(pick):[],kbCommand=site({...api,name:"kb",access:"read",description:"读取文件型 BI 搭建知识库(服务器优先、本地兜底);数据字典请使用 kb-sources / kb-search 等结构化命令",args:[str("file","文件路径,如 knowledge/report-builder/build-spec.md 或 templates/report-spec/report.example.yaml",!1),str("search","按关键字过滤目录",!1),str("library","文件型知识库:bi-builder;data-dictionary 请使用 kb-sources 等结构化命令;另预留 integration-flow | user",!1)],func:async args=>logKb(args.file?"doc":"catalog",await(async()=>{const raw=String(args.file||"").replace(/^\.?\//,"");if(raw.startsWith("templates/"))return{file:raw,source:"bundled",content:readLocal("templates",raw.slice(10))};if(raw==="knowledge/browser/README.md"||raw==="browser/README.md")return{file:"knowledge/browser/README.md",source:"bundled-cli",content:readLocal("knowledge","browser/README.md")};const keyword=args.search?String(args.search):"",wanted=args.library?String(args.library):"";if(wanted&&!core.KB_LIBRARIES[wanted])throw new ArgumentError(`未知知识库:${wanted}`);if(wanted==="data-dictionary"||/^(?:knowledge\/)?data-dictionary(?:\/|$)/.test(raw))throw new CliError("KB_STRUCTURED_API","数据字典不再按 Markdown 文件路径读取","先执行 linkai kb-sources,再使用 kb-search / kb-catalog / kb-object / kb-docs / kb-doc",1);if(wanted&&!core.KB_LIBRARIES[wanted].enabled)throw new CliError("KB_NOT_IMPLEMENTED",`知识库 ${wanted} 已预留,当前版本尚未启用`,void 0,1);if(!raw){const libraries=wanted?[wanted]:["bi-builder"],docs=[];for(const library of libraries)try{const catalog=await core.kbCatalog(library);library==="bi-builder"&&!(catalog.docs||[]).length?docs.push(...localBiDocs()):docs.push(...(catalog.docs||[]).map(d=>{const prefix=core.KB_LIBRARIES[library].pathPrefix,relative=core.kbRequest(d.path,library).path;return{file:`knowledge/${prefix}/${relative}`,title:d.title||"",summary:d.summary||"",library,scope:core.KB_LIBRARIES[library].scope,source:"server"}}))}catch(e){if(library==="bi-builder"&&e instanceof core.KbUnavailableError)docs.push(...localBiDocs());else throw e instanceof core.KbUnavailableError?kbUnavailable(library):e}const templates=!wanted||wanted==="bi-builder"?walk(KB_ROOTS.templates).map(t=>({file:`templates/${t}`,title:"报表模版",summary:"",source:"bundled"})):[];return[...docs,...templates].filter(d=>!keyword||d.file.includes(keyword)||d.title.includes(keyword))}const req=core.kbRequest(raw,wanted||void 0);try{const doc=await core.kbDoc(raw,wanted||void 0);return{file:`knowledge/${doc.path}`,title:doc.title||"",library:doc.library,scope:doc.scope,source:"server",content:doc.content||""}}catch(e){if(req.library==="bi-builder"&&(e instanceof core.KbUnavailableError||e instanceof core.KbDocumentNotFoundError)){if(!fs.existsSync(path.join(KB_ROOTS.knowledge,"report-builder")))throw kbUnavailable(req.library);return localBiDoc(req)}throw e instanceof core.KbUnavailableError?kbUnavailable(req.library):e}})())}),kbSourcesCommand=site({...api,name:"kb-sources",access:"read",defaultFormat:"table",description:"列出当前租户可读的数据字典数据源,并标记当前数据集所用 source;后续命令必须显式传该 ID",args:[],func:async()=>{try{const[{sources=[]},context]=await Promise.all([core.aiKbSources(),core.ctx()]),rows=sources.map(source=>({...source,selectedForDatasets:String(source.source).toLowerCase()===String(context.dataSourceId).toLowerCase()})),aligned=rows.some(source=>source.selectedForDatasets);return logDict("sources",{},null,{sources:rows.map(r=>({source:r.source,name:r.name})),aligned}),{datasetSourceId:context.dataSourceId,aligned,sources:rows,...aligned?{}:{warning:"数据字典没有返回当前数据集 source;不要跨 source 使用元数据,请先确认服务端探库或数据源配置"}}}catch(error){throw aiKbFailure(error)}}}),kbCatalogCommand=site({...api,name:"kb-catalog",access:"read",description:"分页列出指定 source 的可用视图/对象元数据",args:[str("source","数据源 ID(来自 kb-sources,必须与后续数据集 source 一致)"),str("search","表名、中文名或说明关键字",!1),str("page","页码,默认 1",!1),str("page-size","每页 1~100,默认 30",!1)],func:async args=>{try{await requireDatasetSource(args.source);const r=await core.aiKbCatalog({source:args.source,keyword:args.search,pageIndex:intOption(args.page,1,"page",1,2147483647),pageSize:intOption(args["page-size"],30,"page-size",1,100)});return logDict("catalog",args,r,{keyword:args.search??null,total:r?.totalCount??null})}catch(error){throw aiKbFailure(error)}}}),kbObjectCommand=site({...api,name:"kb-object",access:"read",description:"读取指定 source 中一个可见对象的字段与关联关系",args:[str("source","数据源 ID(来自 kb-sources)"),str("db","数据库/schema 名"),str("object","对象/视图名")],func:async args=>{try{await requireDatasetSource(args.source);const r=await core.aiKbObject({source:args.source,db:args.db,object:args.object});return logDict("object",args,r,{object:`${args.db}.${args.object}`})}catch(error){throw aiKbFailure(error)}}}),kbDocsCommand=site({...api,name:"kb-docs",access:"read",description:"分页列出指定 source 的业务口径、枚举、关系、数据点等知识文档",args:[str("source","数据源 ID(来自 kb-sources)"),str("category","caliber | enum | relation | datapoint | other",!1),str("search","标题或正文关键字",!1),str("page","页码,默认 1",!1),str("page-size","每页 1~100,默认 30",!1)],func:async args=>{const category=args.category?String(args.category):void 0;if(category&&!["caliber","enum","relation","datapoint","other"].includes(category))throw new ArgumentError("category 必须是 caliber、enum、relation、datapoint、other 之一");try{await requireDatasetSource(args.source);const r=await core.aiKbDocs({source:args.source,category,keyword:args.search,pageIndex:intOption(args.page,1,"page",1,2147483647),pageSize:intOption(args["page-size"],30,"page-size",1,100)});return logDict("docs",args,r,{category:category??null,keyword:args.search??null,total:r?.totalCount??null})}catch(error){throw aiKbFailure(error)}}}),kbDocCommand=site({...api,name:"kb-doc",access:"read",description:"一致性读取一篇完整数据字典文档;自动按 nextOffset 分页,版本变化时从头重读",args:[str("source","数据源 ID(来自 kb-sources)"),str("doc","文档 ID(来自 kb-docs 或 kb-search)"),str("page-size","单次正文字符数,1~20000,默认 20000",!1)],func:async args=>{try{await requireDatasetSource(args.source);const r=await core.aiKbDoc({source:args.source,doc:args.doc,limit:intOption(args["page-size"],2e4,"page-size",1,2e4)});return logDict("doc",args,r,{doc:args.doc,title:r?.title??null,category:r?.category??null,chars:r?.totalChars??null})}catch(error){throw aiKbFailure(error)}}}),kbSearchCommand=site({...api,name:"kb-search",access:"read",description:"按自然语言检索指定 source 的候选视图与业务知识;相关度不是正确率,使用前继续回读对象/文档核对",args:[str("source","数据源 ID(来自 kb-sources)"),str("question","业务问题,最长 2000 字"),str("top-tables","最多返回表数,1~10,默认 5",!1),str("top-docs","最多返回文档数,1~10,默认 3",!1)],func:async args=>{try{await requireDatasetSource(args.source);const r=await core.aiKbSearch({source:args.source,question:args.question,topTables:intOption(args["top-tables"],5,"top-tables",1,10),topDocs:intOption(args["top-docs"],3,"top-docs",1,10)});return logDict("search",args,r,{question:String(args.question||"").slice(0,200),hitTables:top(r?.tables,10,t=>`${t.db}.${t.object}`),hitDocs:top(r?.docs,10,d=>({doc:d.doc,title:d.title??null,category:d.category??null})),emptyReason:r?.emptyReason??null})}catch(error){throw aiKbFailure(error)}}}),templateCommand=cli({...api,name:"template",access:"write",description:"复制报表 spec 与示例文件;SQL 示例必须由 AI 按用户需求、租户数据字典和确认口径重写,CLI 不生成业务 SQL",args:[str("output","目标目录(默认 ./linkai-report),需不存在或为空",!1)],func:async args=>{const dest=path.resolve(String(args.output||"linkai-report"));if(fs.existsSync(dest)&&fs.readdirSync(dest).length)throw new ArgumentError(`目录已存在且不为空:${dest}`,"换一个 --output 目录");return fs.cpSync(path.join(KB_ROOTS.templates,"report-spec"),dest,{recursive:!0}),{output:dest,files:walk(dest),next:`修改 ${path.join(dest,"report.example.yaml")} 与 sql/ 下的 SQL,然后 linkai build --spec ${path.join(dest,"report.example.yaml")}`}}}),buildCommand=site({...api,name:"build",access:"write",description:"严格按 BI 公共知识库执行 AI 提供的 SQL 与报表 spec(CLI 不生成业务 SQL)。默认只校验并给出执行计划;--execute 才写入;含正式名还需 --confirm",args:[str("spec","报表 spec 文件(.yaml/.json),模版用 linkai template 复制"),{name:"execute",type:"boolean",default:!1,help:"真正写入(默认只校验并输出执行计划)"},{name:"confirm",type:"boolean",default:!1,help:"用户已明确授权使用正式名(非 zz- 前缀)"},{name:"no-style",type:"boolean",default:!1,help:"跳过页面样式步骤(暖色背景、卡位透明)"}],func:async args=>{const loaded=build.loadSpec(args.spec),writesSql=(loaded.spec.datasets||[]).some(d=>!d.id&&d.sql),beforeWrite=async()=>{const session=await core.ensureSession(),cur=core.spaceOf(session);if(loaded.spec.tenant&&![cur.tenantId,cur.tenantName,cur.tenantCode].includes(String(loaded.spec.tenant)))throw new ArgumentError(`当前登录的租户是「${cur.tenantName}」,与 spec.tenant「${loaded.spec.tenant}」不一致`,"确认目标租户后执行 linkai login --tenant <租户名>,或修改 spec.tenant");writesSql&&await checkDictionary({enforce:!0})},result=await build.runBuild(loaded,{execute:!!args.execute,confirm:!!args.confirm,style:!args["no-style"],beforeWrite});if(!args.execute&&writesSql&&result.ok!==!1)try{result.dictionary=await checkDictionary({enforce:!1})}catch(e){if(e instanceof core.LoginRequiredError)result.dictionary={ok:!1,reason:"未登录"};else throw e}if(result.ok===!1&&(result.stage==="validate"||result.stage==="authorize"))throw new ArgumentError(`build ${result.stage} failed:
3
+ - ${result.errors.join(`
4
+ - `)}`,result.stage==="authorize"?"把正式名清单给用户确认,得到明确授权后再加 --confirm":"按错误逐条修改 spec 后重新 dry-run");return result}});async function publishPlanKnowledgeFromFile(args){const mode=String(args.mode||"");if(!["create","update"].includes(mode))throw new ArgumentError("必须明确 --mode create 或 --mode update;用户未说明新建还是修改时,先主动询问用户");if(!args.file)throw new ArgumentError("缺少 --file <方案知识 YAML/JSON>","文件必须描述方案内容、解决的问题,以及每张业务卡片的内容、数据来源、粒度和取数逻辑");const input=deliver.readKnowledgeFile(args.file);return deliver.publishPlanKnowledge({planId:args.plan,operation:mode,authoring:input.knowledge||input})}const planKnowledgeCommand=site({...api,name:"plan-knowledge",access:"write",description:"生成、版本化并保存完整方案知识库;服务器不可用时内部留存本地。最终返回方案地址和完整知识内容,不返回 SQL 或上传信息",args:[str("plan","方案的精确 id"),str("mode","create(新方案)或 update(修改原方案)",!1),str("file","AI 编写的方案知识 YAML/JSON 文件",!1)],func:async args=>{const r=await publishPlanKnowledgeFromFile(args);return oplog.emit("kb.write",{library:"plan-knowledge",op:args.mode||"create",plan:args.plan,version:r?.version??r?.planKnowledge?.version??null}),r}}),planKnowledgeGetCommand=site({...api,name:"plan-knowledge-get",access:"read",description:"读取方案当前知识库;修改原方案前先读。服务器不可用时读取本地待同步版本,不展示存储信息",args:[str("plan","方案的精确 id")],func:async args=>{const r=await deliver.readCurrentPlanKnowledge(args.plan);return oplog.emit("kb.read",{library:"plan-knowledge",op:"doc",plan:args.plan,version:r?.version??null,exists:r?.exists??null,chars:typeof r?.content=="string"?r.content.length:null}),r}}),deliverableCommand=site({...api,name:"deliverable",access:"write",description:"兼容命令:等同 plan-knowledge;需要 --mode 与 --file,不再输出数据集 SQL",args:[str("plan","方案的精确 id"),str("mode","create 或 update",!1),str("file","方案知识 YAML/JSON 文件",!1)],func:publishPlanKnowledgeFromFile}),backupCommand=site({...api,name:"backup",access:"read",description:"修改已有对象前保存带时间戳的备份(卡片/方案 JSON 或数据集 SQL),存到 ~/.linkai/artifacts/linkai/backups/",args:[str("type","card(卡片)| plan(方案)| ds(数据集)"),str("id","对象的精确 id")],func:async args=>{const at=ext=>core.artifactsDir("backups",`${args.type}-${args.id}-${stamp()}.${ext}`);if(args.type==="card")return{type:"card",output:core.writeJsonFile(at("json"),await core.cardDetail(args.id))};if(args.type==="plan")return{type:"plan",output:core.writeJsonFile(at("json"),await core.planDetail(args.id,!0))};if(args.type==="ds")return{type:"ds",output:core.writeJsonFile(at("sql"),(await core.dsDetail(args.id)).sql||"")};throw new Error("type must be card, plan or ds")}}),removeCommand=site({...api,name:"remove",access:"write",description:"按 folderId 删除数据集/卡片/方案/报表(id 来自各 *-list 命令)。需要用户明确授权",args:[str("folder","精确的 folderId,多个用逗号分隔")],func:async args=>{const ids=String(args.folder).split(",").map(s=>s.trim()).filter(Boolean);return await core.post("/index-block/delete-folder-bussiness",ids),{deleted:ids.length,recycleBin:"POST /index-block/get-recycle-station-page-list"}}}),cleanCommand=site({...api,name:"clean",access:"write",description:"按 zz- 前缀清理试验对象,按依赖顺序删除:报表 → 方案 → 卡片 → 数据集",args:[str("prefix","以 zz- 开头的前缀,如 zz-销售看板-")],func:async args=>core.cleanPrefix(args.prefix)}),apiGetCommand=site({...api,name:"api-get",access:"read",description:"调用未封装的读接口(GET,路径可省略 /api/app);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[str("path","接口路径,如 /index-block/folders?type=1"),{name:"raw",type:"boolean",default:!1,help:"返回完整响应"}],func:async args=>(core.assertApiAllowed(args.path),core.api("GET",args.path,void 0,{raw:!!args.raw}))}),READ_POST_API=/\/(get-|query|search)[\w-]*|\/[\w-]*-(list|detail|info)(?:[/?]|$)/i,apiCommand=site({...api,name:"api",access:"write",accessOf:args=>READ_POST_API.test(String(args.path||""))?"read":"write",description:"调用未封装的写接口(POST/PUT/DELETE,可能新建或修改数据);先查 references/api-catalog.md。数据源、SQL 类接口不开放",args:[str("method","POST、PUT 或 DELETE"),str("path","接口路径"),str("body","请求体 JSON 文件路径",!1),{name:"raw",type:"boolean",default:!1,help:"返回完整响应(code/message/extendData)"}],func:async args=>{const m=String(args.method).toUpperCase();if(!["POST","PUT","DELETE"].includes(m))throw new Error("method must be POST, PUT or DELETE; use api-get for GET");return core.assertApiAllowed(args.path),core.api(m,args.path,args.body?json(args.body):m==="POST"?{}:void 0,{raw:!!args.raw})}}),FLOW_TYPES_HELP="create(新建方案)| modify(修改已有方案)| import(导入/更新数据)| query(只读查询分析)",flowStartCommand=site({...api,name:"flow-start",access:"write",description:"开始一个工作流:接到业务任务时执行一次,之后的命令自动归入它;完成后用 flow-end 结束",args:[str("goal","这个任务要完成什么(用户原话概括)"),str("type",FLOW_TYPES_HELP),str("plan","目标方案 id(修改已有方案时)",!1),str("model",'你(AI)当前使用的具体模型与版本,如 "Claude Opus 5.5"、"gpt-5.6";不确定就写你所知道的名称',!1)],func:async a=>{const f=oplog.startFlow({goal:a.goal,type:String(a.type||"").trim(),plan:a.plan||null,model:a.model||null});return{flow:f.id,goal:f.goal,type:f.type,plan:f.plan,model:oplog.currentModel(),next:"同一会话里的后续命令自动归入此工作流;并行开多个任务时,在命令上加 --flow <id> 指明。完成后 linkai flow-end --status delivered"}}}),flowEndCommand=site({...api,name:"flow-end",access:"write",description:"结束工作流:delivered(已交付)| abandoned(放弃)| failed(失败);默认结束当前工作流,或用 --flow <id> 指定",args:[str("status","delivered | abandoned | failed"),str("plan","交付的方案 id",!1),str("knowledge-version","方案知识库版本,如 v2",!1),str("note","结果说明或失败原因",!1)],func:async a=>{const result=Object.fromEntries(Object.entries({plan:a.plan,knowledgeVersion:a["knowledge-version"],note:a.note}).filter(([,v])=>v!=null&&v!=="")),f=oplog.endFlow({id:process.env.LINKAI_FLOW||void 0,status:String(a.status||"").trim(),result});return{flow:f.id,status:f.status,goal:f.goal,runs:f.runs,durationMinutes:Math.round(((f.endedAt??Date.now())-f.startedAt)/6e3)/10,result:f.result}}}),flowStatusCommand=site({...api,name:"flow-status",access:"read",description:"查看本机工作流:当前工作流、未完成或中断的工作流(含中断原因与下一步);--all 含已结束的",args:[{name:"all",type:"boolean",default:!1,help:"包括已结束和自动关闭的工作流(最近 7 天)"}],func:async a=>{const current=oplog.currentFlow(),list=(a.all?oplog.listFlows():oplog.unfinishedFlows()).slice(0,30).map(f=>({...describeFlow(f),runs:f.runs,startedAt:new Date(f.startedAt).toISOString(),endedAt:f.endedAt?new Date(f.endedAt).toISOString():null,result:f.result}));return{current,idleMinutes:oplog.flowIdleMinutes(),flows:list}}}),workerArg={name:"worker",type:"string",required:!1,help:"执行者名称(也可用全局参数 --worker);同一会话保持不变,如 主控、sql-1、card-2"},workerOf=a=>String(a.worker||collab.currentWorker()||""),boardArg=str("board","任务板 id(collab-start 返回,如 b-1a2b3c4d)"),collabStartCommand=site({...api,name:"collab-start",access:"write",description:"多会话协同:创建任务板(协调者执行一次),返回任务板 id 交给各执行者",args:[str("goal","这次协同要完成什么"),str("plan","目标方案 id(修改已有方案时)",!1),str("type",`工作流类型:${FLOW_TYPES_HELP};默认有 --plan 为 modify,否则 create`,!1),workerArg],func:async a=>{const board=collab.startBoard({goal:a.goal,plan:a.plan,worker:workerOf(a)});return oplog.startFlow({id:board.id,source:"board",goal:board.goal,type:a.type||(a.plan?"modify":"create"),plan:a.plan||null}),{board:board.id,coordinator:board.coordinator,goal:board.goal,next:"用 collab-task 拆分任务;各执行者写共享对象前先 collab-claim,成果用 collab-note 共享;读命令不用认领"}}}),collabTaskCommand=site({...api,name:"collab-task",access:"write",description:"多会话协同:新增任务(--title),或更新任务状态(--id --status todo|doing|done|blocked --result)",args:[boardArg,str("title","新任务标题(新增时)",!1),{name:"id",type:"int",required:!1,help:"要更新的任务编号"},str("status","todo | doing | done | blocked",!1),str("result","结果或阻塞原因(文件路径、对象 id、结论)",!1),str("assignee","分配给哪个执行者",!1),str("object","任务涉及的共享对象,逗号分隔,如 card:<id>,ds:<id>",!1),workerArg],func:async a=>a.id?collab.updateTask({board:a.board,worker:workerOf(a),id:a.id,status:a.status,result:a.result,assignee:a.assignee}):collab.addTask({board:a.board,worker:workerOf(a),title:a.title,assignee:a.assignee,objects:a.object})}),collabClaimCommand=site({...api,name:"collab-claim",access:"write",description:"多会话协同:写共享对象前认领(全部成功或全部不认领;重复认领即续期);被别人认领时返回 COLLAB_CONFLICT",args:[boardArg,str("object","对象,逗号分隔:card:<id> ds:<id> plan:<id> knowledge:<planId> spec:<文件> report:<id> filter:<planId>"),{name:"ttl",type:"int",default:collab.DEFAULT_TTL_SECONDS,help:"认领有效秒数(60-7200),到期自动释放"},str("note","在做什么",!1),workerArg],func:async a=>collab.claim({board:a.board,worker:workerOf(a),objects:a.object,ttl:a.ttl,note:a.note})}),collabReleaseCommand=site({...api,name:"collab-release",access:"write",description:"多会话协同:释放认领(不指定对象则释放自己在该任务板上的全部认领)",args:[boardArg,str("object","要释放的对象,逗号分隔",!1),workerArg],func:async a=>collab.release({board:a.board,worker:workerOf(a),objects:a.object})}),collabNoteCommand=site({...api,name:"collab-note",access:"write",description:"多会话协同:共享成果或发现(已验证的 SQL 文件、建好的对象 id、口径结论、交接、阻塞),避免别人重复做",args:[boardArg,str("text","内容"),str("kind","finding | result | handoff | blocker | decision(默认 finding)",!1),str("ref","相关文件路径或对象 id",!1),workerArg],func:async a=>collab.note({board:a.board,worker:workerOf(a),text:a.text,kind:a.kind||"finding",ref:a.ref})}),collabStatusCommand=site({...api,name:"collab-status",access:"read",description:"多会话协同:查看任务板(任务、有效认领、最近成果);不带 --board 列出全部进行中的任务板",args:[str("board","任务板 id",!1),{name:"notes",type:"int",default:30,help:"显示最近多少条成果记录"}],func:async a=>collab.status({board:a.board,notes:a.notes})}),collabCloseCommand=site({...api,name:"collab-close",access:"write",description:"多会话协同:关闭任务板并释放其上全部认领,同时结束对应工作流(协调者在交付后执行)",args:[boardArg,str("status","工作流结束状态:delivered | abandoned | failed(默认 delivered)",!1),str("note","结果说明",!1),workerArg],func:async a=>{const closed=collab.closeBoard({board:a.board,worker:workerOf(a)});let flow=null;try{flow=oplog.endFlow({id:closed.board,status:a.status||"delivered",result:a.note?{note:a.note}:null}).status}catch(e){flow=`未结束:${e.message}`}return{...closed,flow}}}),shotCommand=site({...ui,name:"shot",access:"write",description:"在浏览器中打开报表/方案页面并截图(需要 Chrome 与 LinkAI 扩展)",args:[str("url","页面地址,如 https://bi.linkmore.com/view/plan?plan=<planId>"),str("output","截图保存路径(默认 ~/.linkai/artifacts/linkai/shots/<时间>.png)",!1),{name:"wait",type:"int",default:12,help:"等待页面渲染的秒数"}],func:async(page,args)=>{const config=core.readConfig(),target=new URL(args.url);if(target.origin!==config.origin)throw new Error(`URL must be on ${config.origin}`);if(/\/login/i.test(target.pathname))throw new Error("Do not open /login: it clears localStorage");const session=await core.ensureSession();await page.goto(`${config.origin}/`),await page.evaluate(core.browserSessionScript(session)),await page.goto(target.href),await page.sleep(Number(args.wait));const state=await page.evaluate("(() => ({ title: document.title, onLogin: /login/i.test(location.pathname) }))()");if(state?.onLogin)throw new Error("Page redirected to /login: the synced session was rejected; run: linkai login");let output=null,screenshotError=null;try{const file=path.resolve(outPath(args.output,"shots",`${stamp()}.png`));fs.mkdirSync(path.dirname(file),{recursive:!0}),output=await page.screenshot({path:file,fullPage:!1,format:"png"})||file}catch(e){screenshotError=String(e?.message||e).slice(0,200)}return{url:await page.getCurrentUrl(),title:state?.title,loggedIn:!0,output,screenshotError}}});export{apiCommand,apiGetCommand,backupCommand,buildCommand,cardChartCommand,cardGanttCommand,cardGetCommand,cardListCommand,cardMeterCommand,cardSaveCommand,cardSortCommand,cardTableCommand,cleanCommand,collabClaimCommand,collabCloseCommand,collabNoteCommand,collabReleaseCommand,collabStartCommand,collabStatusCommand,collabTaskCommand,deliverableCommand,doctorCommand,dsCreateCommand,dsFieldsCommand,dsListCommand,dsMatchCommand,dsRefsCommand,dsSqlCommand,dsTablesCommand,dsUpdateCommand,fillWriteCommand,flowEndCommand,flowStartCommand,flowStatusCommand,fmtCommand,kbCatalogCommand,kbCommand,kbDocCommand,kbDocsCommand,kbObjectCommand,kbSearchCommand,kbSourcesCommand,kindsCommand,loginCommand,logoutCommand,planAddCommand,planCreateCommand,planFilterCommand,planGetCommand,planKnowledgeCommand,planKnowledgeGetCommand,planListCommand,queryCommand,removeCommand,reportCardCommand,reportListCommand,reportPublishCommand,resetFilterCommand,shotCommand,sqlCheckCommand,templateCommand,tenantsCommand};
@@ -0,0 +1,12 @@
1
+ import fs from"node:fs";import path from"node:path";import os from"node:os";import crypto from"node:crypto";import{authorizeTenantSession,loginWithOauth,refreshOauthSession,revokeOauthSession}from"./oauth.js";import{validateFillCardPayload}from"./fill-card.js";import{checkCardPayload,checkFilterPayload}from"./card-rules.js";import*as oplog from"@linkaicli/linkai-cli/oplog";import*as collab from"./collab.js";import TEMPLATES from"./templates.js";const SITE_ID="linkai",Z="00000000-0000-0000-0000-000000000000",DEFAULT_SITE="https://bi.linkmore.com/",DEFAULT_API="https://bi-api.linkmore.com",RETRYABLE=/UND_ERR_CONNECT_TIMEOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|Connect Timeout/,STALE_SOCKET=/EPIPE|ECONNRESET|socket hang up|UND_ERR_SOCKET|other side closed/i,IDEMPOTENT=new Set(["GET","HEAD"]);class NetworkError extends Error{constructor(message,options){super(message,options),this.name="NetworkError"}}async function fetchRetry(url,opts,tries=3){for(let i=1;;i++)try{return await fetch(url,opts)}catch(e){if(opts?.signal?.aborted)throw e;const why=`${e?.cause?.code||""} ${e?.cause?.message||""} ${e?.message||""}`,retryable=RETRYABLE.test(why)||IDEMPOTENT.has(String(opts?.method||"GET").toUpperCase())&&STALE_SOCKET.test(why);if(i>=tries||!retryable)throw new NetworkError(`无法连接领谋 BI 服务器,请检查网络后重试(${why.trim().replace(/\s+/g," ").slice(0,120)})`,{cause:e});await new Promise(r=>setTimeout(r,1500*i))}}function configDir(){const dir=process.env.LINKAI_SITE_CONFIG_DIR||path.join(os.homedir(),".linkai","sites");return fs.mkdirSync(dir,{recursive:!0,mode:448}),migrateLegacyFiles(dir),dir}let _migrated=!1;function migrateLegacyFiles(dir){if(!_migrated){_migrated=!0;for(const[from,to]of[["lmbi.json",`${SITE_ID}.json`],["lmbi.session.json",`${SITE_ID}.session.json`]]){const a=path.join(dir,from),b=path.join(dir,to);try{fs.existsSync(a)&&!fs.existsSync(b)&&fs.renameSync(a,b)}catch{}}if(!process.env.LINKAI_ARTIFACTS_DIR){const a=path.join(os.homedir(),".linkai","artifacts","lmbi"),b=artifactsDir();try{fs.existsSync(a)&&!fs.existsSync(b)&&fs.renameSync(a,b)}catch{}}}}const configFile=()=>path.join(configDir(),`${SITE_ID}.json`);function artifactsDir(...sub){return path.join(process.env.LINKAI_ARTIFACTS_DIR||path.join(os.homedir(),".linkai","artifacts",SITE_ID),...sub)}function readConfig(){const config=readConfigFile();if("username"in config||"password"in config||"tenantConfirmed"in config){const{username,password,tenantConfirmed,...safe}=config;return writeConfigFile(safe),configFrom(safe)}return configFrom(config)}function configFrom(config){const url=new URL(config.url||DEFAULT_SITE);if(!["http:","https:"].includes(url.protocol))throw new Error("LMBI url must use http:// or https://");return{...config,url:url.href,origin:url.origin,apiBase:(config.apiBase||DEFAULT_API).replace(/\/$/,"")}}const sessionFile=()=>path.join(configDir(),`${SITE_ID}.session.json`),LOGIN_HINT="请用户执行 linkai login,在浏览器完成领谋授权";function readSession(){try{const j=JSON.parse(fs.readFileSync(sessionFile(),"utf8"));return j.authMode==="oauth"&&j.token&&j.refreshToken&&j.tenantId?j:null}catch{return null}}const shortCacheFile=()=>path.join(configDir(),`${SITE_ID}.cache.json`);async function shortCache(key,ttlMs,fn){const tenantId=readSession()?.tenantId;if(!tenantId||process.env.LINKAI_NO_CACHE==="1")return fn();let all={};try{all=JSON.parse(fs.readFileSync(shortCacheFile(),"utf8"))||{}}catch{}all.tenantId!==tenantId&&(all={tenantId,entries:{}});const hit=all.entries?.[key];if(hit&&Date.now()-hit.at>=0&&Date.now()-hit.at<ttlMs)return hit.value;const value=await fn();try{try{all=JSON.parse(fs.readFileSync(shortCacheFile(),"utf8"))||{}}catch{all={}}all.tenantId!==tenantId&&(all={tenantId,entries:{}}),all.entries??={},all.entries[key]={at:Date.now(),value};const tmp=`${shortCacheFile()}.${process.pid}.tmp`;fs.writeFileSync(tmp,JSON.stringify(all),{mode:384}),fs.renameSync(tmp,shortCacheFile())}catch{}return value}function clearShortCache(){try{fs.rmSync(shortCacheFile(),{force:!0})}catch{}}function saveSession(session){if(session?.authMode!=="oauth"||!session.token||!session.refreshToken||!session.tenantId||!session.agentId)throw new Error("只允许保存完整的 OAuth 会话");const file=sessionFile(),tmp=`${file}.${process.pid}.tmp`,data={token:session.token,tenantId:session.tenantId,agentId:session.agentId||"",...session.authMode==="oauth"&&session.refreshToken?{authMode:"oauth",refreshToken:session.refreshToken}:{},savedAt:new Date().toISOString()};return fs.writeFileSync(tmp,JSON.stringify(data),{mode:384}),fs.renameSync(tmp,file),_session=data,_ctx=null,_allow=null,data}function clearSession(){try{fs.rmSync(sessionFile())}catch{}_session=null,_ctx=null,_allow=null}function tokenPayload(token){try{return JSON.parse(Buffer.from(String(token).split(".")[1],"base64").toString())}catch{return{}}}function tokenHoursLeft(token){const p=tokenPayload(token);return p.exp?(p.exp*1e3-Date.now())/36e5:-1}function spaceOf(session){const p=tokenPayload(session?.token);let info={};try{info=JSON.parse(p.TenantInfo||"[]")[0]||{}}catch{}return{tenantId:session?.tenantId,tenantName:info.tenantName||"",tenantCode:info.tenantCode||"",user:p.UserName||""}}class LoginRequiredError extends Error{constructor(reason){super(`${reason}。${LOGIN_HINT}`),this.name="LoginRequiredError"}}function readConfigFile(){try{return JSON.parse(fs.readFileSync(configFile(),"utf8"))||{}}catch{return{}}}function writeConfigFile(data){const file=configFile(),tmp=`${file}.${process.pid}.tmp`;fs.writeFileSync(tmp,JSON.stringify(data,null,2),{mode:384}),fs.renameSync(tmp,file)}function forgetCredentials(){const{username,password,tenantId,tenantConfirmed,...rest}=readConfigFile();if(Object.keys(rest).length)writeConfigFile(rest);else try{fs.rmSync(configFile())}catch{}}function oauthAgentId(){forgetCredentials();const c=readConfigFile();if(typeof c.oauthAgentId=="string"&&/^[A-Za-z0-9._:-]{8,128}$/.test(c.oauthAgentId))return c.oauthAgentId;const agentId=crypto.randomUUID().replace(/-/g,"");return writeConfigFile({...c,oauthAgentId:agentId}),agentId}async function oauthLogin({tenant,switchAccount=!1,timeoutSeconds=300,browser}={}){const old=readSession();return loginWithOauth(readConfig(),{agentId:oauthAgentId(),tenant,switchAccount,timeoutSeconds,browser,onAuthorized:async session=>{if(switchAccount&&old){const previousUser=tokenPayload(old.token).UserId,nextUser=tokenPayload(session.token).UserId;if(previousUser&&nextUser&&previousUser===nextUser)throw await revokeOauthSession(readConfig(),session).catch(()=>{}),new Error("授权后仍是原领谋账号;请在授权页切换账号后重试")}if(tenant)try{const list=await tenantList(session),w=String(tenant).trim(),hit=list.filter(t=>t.tenantId===w||t.code===w||t.name===w),fuzzy=hit.length?hit:list.filter(t=>t.name.includes(w));if(fuzzy.length!==1||fuzzy[0].tenantId!==session.tenantId)throw new Error(`授权的租户不是「${w}」,请在领谋授权页选择目标租户后重试`)}catch(error){throw await revokeOauthSession(readConfig(),session).catch(()=>{}),error}forgetCredentials();const saved=saveSession(session);return old?.refreshToken&&await revokeOauthSession(readConfig(),old).catch(()=>{}),{session:saved,tenantName:spaceOf(saved).tenantName||saved.tenantId}}})}async function oauthLogout(){const session=readSession();if(!session?.refreshToken)return!1;try{return await revokeOauthSession(readConfig(),session)}catch{return!1}}let _session=null,_oauthRefresh=null;async function withSessionLock(task){const lock=`${sessionFile()}.refresh.lock`,deadline=Date.now()+3e4;let fd;for(;fd===void 0;)try{fd=fs.openSync(lock,"wx",384),fs.writeFileSync(fd,String(process.pid))}catch(error){if(error.code!=="EEXIST")throw error;try{const owner=Number(fs.readFileSync(lock,"utf8"));if(Number.isInteger(owner)&&owner>0){let alive=!0;try{process.kill(owner,0)}catch(check){check.code==="ESRCH"&&(alive=!1)}if(!alive){fs.rmSync(lock);continue}}}catch{}if(Date.now()>=deadline)throw new Error("等待 OAuth 会话操作超时,请稍后重试");await new Promise(resolve=>setTimeout(resolve,150))}try{return await task()}finally{fs.closeSync(fd),fs.rmSync(lock)}}async function refreshOauthLocked(previous){return withSessionLock(async()=>{const current=readSession();if(!current?.refreshToken)throw new LoginRequiredError("OAuth 会话已在本机退出");return current.refreshToken!==previous.refreshToken&&tokenHoursLeft(current.token)>.05?(_session=current,current):saveSession(await refreshOauthSession(readConfig(),current))})}async function ensureSession({refresh=!1}={}){if(forgetCredentials(),_session&&!refresh&&tokenHoursLeft(_session.token)>.05)return _session;const saved=_session||readSession();if(saved&&!refresh&&tokenHoursLeft(saved.token)>.05)return _session=saved,saved;if(saved?.authMode==="oauth"&&saved.refreshToken)return _oauthRefresh||(_oauthRefresh=(async()=>{try{return await refreshOauthLocked(saved)}catch(error){if(["invalid_grant","invalid_client","oauth_exchange_unknown"].includes(error.code)){const newer=readSession();if(newer&&newer.refreshToken!==saved.refreshToken&&tokenHoursLeft(newer.token)>.05)return _session=newer,newer;throw clearSession(),new LoginRequiredError(error.code==="oauth_exchange_unknown"?"OAuth 续期结果无法确认":"OAuth 授权已失效")}throw error}})().finally(()=>{_oauthRefresh=null})),_oauthRefresh;throw new LoginRequiredError(saved?"登录已过期":"尚未登录领谋 BI")}async function tenantList(session){session=session||await ensureSession();const p=tokenPayload(session.token),j=await rawFetch(readConfig(),session,"GET",`/api/app/account/user-bind-tenant-list/${p.UserId||p.sub||p.userId}`);if(j.code!==200)throw new LoginRequiredError(`读取租户列表失败(${j.message||j.code})`);return(j.data||[]).map(x=>({tenantId:x.tenantId,name:x.spaceName||x.tenantName||"",code:x.tenantCode||""})).filter(t=>!isPlatformTenant(t))}function isPlatformTenant(t){return t.tenantId===Z||t.code==="0000"||t.name==="全域租户"||t.name==="全域"}async function switchTenant(session,wanted){if(session?.authMode!=="oauth")throw new LoginRequiredError("当前没有 OAuth 授权");return withSessionLock(async()=>{let current=readSession();if(!current)throw new LoginRequiredError("当前没有 OAuth 授权");if(tokenPayload(current.token).UserId!==tokenPayload(session.token).UserId)throw new Error("领谋账号已切换,请重新读取当前租户");tokenHoursLeft(current.token)<=.05&&(current=saveSession(await refreshOauthSession(readConfig(),current)));const list=await tenantList(current),w=String(wanted).trim(),hit=list.filter(t=>t.tenantId===w||t.code===w||t.name===w),fuzzy=hit.length?hit:list.filter(t=>t.name.includes(w));if(fuzzy.length!==1)throw new Error(`租户「${w}」${fuzzy.length?"匹配到多个":"不存在"};可选:${list.map(t=>`${t.name}(${t.tenantId})`).join("、")}`);const target=fuzzy[0];if(target.tenantId===current.tenantId)return{session:current,tenant:target,switched:!1};const next=await authorizeTenantSession(readConfig(),current,target.tenantId);return saveSession(next),_tenantCardIds=null,await revokeOauthSession(readConfig(),current).catch(()=>{}),{session:next,tenant:target,switched:!0}})}let _clientVersion="";function setClientVersion(version){_clientVersion=String(version||"")}function logPath(p){const[pathname,query2=""]=String(p).split("?"),keys=query2?[...new URLSearchParams(query2).keys()]:[];return{path:pathname,query:keys.length?keys:void 0}}function identityOf(session){if(!session)return null;const p=tokenPayload(session.token);return{userId:String(p.UserId||p.sub||p.userId||""),tenantId:session.tenantId,tenantCode:spaceOf(session).tenantCode}}function logGuard(check,pass,extra={}){oplog.emit("guard",{check,pass,...extra})}async function rawFetch(config,session,method,p,body,{log=!0,signal}={}){const started=Date.now(),reqText=body!==void 0?JSON.stringify(body):void 0;log&&oplog.setIdentity(identityOf(session));let r;try{r=await fetchRetry(config.apiBase+p,{method,headers:{"X-LinkAI-Run-Id":oplog.currentRunId(),..._clientVersion?{"X-LinkAI-Version":_clientVersion}:{},Authorization:"Bearer "+session.token,Tenantid:session.tenantId,Agentid:session.agentId||"","Content-Type":"application/json"},...reqText!==void 0?{body:reqText}:{},...signal?{signal}:{}})}catch(error){throw log&&oplog.emit("api.call",{method,...logPath(p),ok:!1,network:!0,ms:Date.now()-started,reqBytes:reqText?Buffer.byteLength(reqText):0,message:String(error.message).slice(0,300)}),error}const text=await r.text();let j;try{j=JSON.parse(text),j.__status=r.status}catch{j={code:r.status,message:`HTTP ${r.status} non-JSON: ${text.slice(0,200)}`,__status:r.status}}if(log){const ok=r.status<400&&j.code===200;oplog.emit("api.call",{method,...logPath(p),status:r.status,code:j.code??null,ok,ms:Date.now()-started,reqBytes:reqText?Buffer.byteLength(reqText):0,resBytes:Buffer.byteLength(text),...ok?{}:{message:String(j.message||"").slice(0,300)}})}return j}const OPLOG_API="/api/app/linkai-oplog/events";oplog.setTransport(async(events,signal)=>{if(!readSession())return!1;const expired=j=>j.__status===401||j.code===401||j.__status===406||j.code===406;try{let session=await ensureSession(),j=await rawFetch(readConfig(),session,"POST",OPLOG_API,{events},{log:!1,signal});return expired(j)&&(session=await ensureSession({refresh:!0}),j=await rawFetch(readConfig(),session,"POST",OPLOG_API,{events},{log:!1,signal})),j.__status<400&&j.code===200?(oplog.saveServerConfig(j.data||{}),!0):!1}catch{return!1}});const BLOCKED_API=[/data-?source/i,/sql/i,/table-field/i,/table-info/i,/er-table/i,/field-info-by/i,/data-info-by/i,/index-block-data-argument/i,/copy-index-block-data/i,/\/export\/import/i,/task-sync/i,/data-warehouse/i,/authority-variable/i,/\/test\//i,/package/i,/subscribe/i,/distribute/i];function assertApiAllowed(p){let decoded=String(p);try{for(let k=0;k<3;k++)decoded=decodeURIComponent(decoded)}catch{}const hit=BLOCKED_API.find(re=>re.test(decoded)||re.test(String(p)));if(hit)throw logGuard("api-blocklist",!1,{path:String(p).split("?")[0],rule:String(hit)}),new Error(`Rejected: ${p} touches data sources / SQL / database structure, which this CLI does not allow. Use ds-create / ds-update for datasets; database changes must go through the LinkMore data team.`)}async function api(method,p,body,{raw=!1}={}){const config=readConfig();p=String(p).trim(),p.startsWith("/")||(p="/"+p),p.startsWith("/api/")||(p="/api/app"+p);try{validateFillCardPayload(method,p,body)}catch(error){throw logGuard("fill-card-structure",!1,{path:p.split("?")[0],message:error.message.slice(0,300)}),error}try{let notes;try{notes=checkCardPayload(method,p,body)}catch(error){const id=/update-new/i.test(p)&&body?.id&&body.id!==Z?body.id:null,previous=id&&error.errors?await cardDetail(id).catch(()=>null):null;if(!previous)throw error;notes=checkCardPayload(method,p,body,{previous})}for(const note of notes)oplog.addNotice(`卡片保存:${note}。`,"card-rules");let filterNotes;try{filterNotes=checkFilterPayload(method,p,body)}catch(error){const planId=body?.userPageSettingId&&Number(body.filterSourceType)!==2?body.userPageSettingId:null,previous=planId&&error.errors?(await planDetail(planId).catch(()=>null))?.filters:null;if(!previous)throw error;filterNotes=checkFilterPayload(method,p,body,{previous})}for(const note of filterNotes)oplog.addNotice(`筛选条件:${note}。`,"filter-rules")}catch(error){throw logGuard(/filter/.test(p)?"filter-rules":"card-rules",!1,{path:p.split("?")[0],message:error.message.slice(0,300)}),new KindError("CONFIG_INVALID",error.message)}const guardTarget=tenantGuardTarget(method,p,body);if(guardTarget){collab.assertWritable([collab.normalizeObject(`card:${guardTarget}`)]);try{await assertCardInTenant(guardTarget),logGuard("tenant-ownership",!0,{target:guardTarget,path:p.split("?")[0]})}catch(error){throw error instanceof TenantMismatchError&&logGuard("tenant-ownership",!1,{target:guardTarget,path:p.split("?")[0]}),error}}let session=await ensureSession(),j=await rawFetch(config,session,method,p,body);const expired=x=>x.__status===401||x.code===401||x.__status===406||x.code===406;if(expired(j)){if(!session.refreshToken)throw new LoginRequiredError("OAuth 授权已失效");if(session=await ensureSession({refresh:!0}),j=await rawFetch(config,session,method,p,body),expired(j))throw new LoginRequiredError("自动续登后仍被拒绝")}if(delete j.__status,raw)return j;if(j.code!==200)throw new ApiError(method,p,j);return j.data}class KindError extends Error{constructor(kind,message){super(message),this.name="KindError",this.kind=kind}}const API_ERROR_KINDS=[["SQL_INVALID",/cannot be resolved|analyzing error|syntax|语法错误|Unknown column|doesn't exist/i],["IN_USE",/被.{0,40}引用.{0,20}请先删除/],["DUPLICATE_NAME",/名称不能重复|名称已存在|已存在同名/],["READ_ONLY",/系统报表不能更改|系统方案或大屏不能|系统.{0,6}(只读|不能(更改|修改|新增|删除))/],["NOT_FOUND",/已被删除|不存在|Id有误|未获取到该信息|未找到该数据/],["FORBIDDEN",/私有|暂无权限|没有权限/],["BAD_REQUEST",/ModelState is not valid|Cannot deserialize/]];function cleanApiMessage(text){return String(text||"").split(/\s*STACK TRACE:/)[0].replace(/\s*\w*Exception:\s*/g," ").replace(/\s+/g," ").trim()}class ApiError extends Error{constructor(method,p,j={}){const main=cleanApiMessage(j.message),detail=cleanApiMessage(j.error&&j.error.details||""),text=detail&&!main.includes(detail)&&!detail.includes(main)?`${main} ${detail}`:detail.length>main.length?detail:main;super(`LMBI API error [${method} ${p.split("?")[0]}]: ${text.slice(0,400)}`),this.name="ApiError",this.path=p.split("?")[0],this.status=j.code,this.kind=API_ERROR_KINDS.find(([,re])=>re.test(text))?.[0]||null}}const post=(p,b)=>api("POST",p,b),get=p=>api("GET",p);let _ctx=null;async function ctx(){if(_ctx)return _ctx;const config=readConfig();let dataSourceId=process.env.LM_DATASOURCE||config.dataSourceId||"",dataSources=[];if(!dataSourceId){if(dataSources=await shortCache("dataSources",30*6e4,async()=>{const ds=await get("/index-block/index-block-data-source");return(Array.isArray(ds)?ds:ds.items||[]).map(x=>({id:x.id,name:x.name}))}),!dataSources.length)throw new Error("当前租户没有可用的数据源,请联系领谋开通");dataSourceId=dataSources[0].id}const folders=await shortCache("rootFolders",30*6e4,async()=>{const[planF,cardF,dsF]=await Promise.all([get("/index-block/folders?type=0"),get("/index-block/folders?type=1"),get("/index-block/folders?type=3")]);return{planFolder:planF[0]?.id||Z,cardFolder:cardF[0]?.id||Z,dsFolder:dsF[0]?.id||Z}});return _ctx={dataSourceId,dataSources,...folders},_ctx}function sqlTables(sql){const s=sql.replace(/\/\*[\s\S]*?\*\//g," ").replace(/--[^\n]*/g," ").replace(/'(?:[^'\\]|\\.)*'/g,"''").replace(/`/g,""),ctes=new Set([...s.matchAll(/(?:\bWITH(?:\s+RECURSIVE)?|,)\s*([A-Za-z_]\w*)\s*(?:\([^()]*\))?\s*AS\s*(?:NOT\s+)?(?:MATERIALIZED\s*)?\(/gi)].map(m=>m[1].toLowerCase())),item="([A-Za-z_][\\w.]*)(?:\\s+(?:AS\\s+)?(?!(?:WHERE|GROUP|ORDER|HAVING|LIMIT|UNION|JOIN|LEFT|RIGHT|INNER|CROSS|FULL|ON)\\b)[A-Za-z_]\\w*)?",froms=[];for(const m of s.matchAll(new RegExp(`\\b(?:FROM|JOIN)\\s+${item}((?:\\s*,\\s*${item})*)`,"gi"))){froms.push(m[1]);for(const x of(m[2]||"").matchAll(new RegExp(`,\\s*${item}`,"gi")))froms.push(x[1])}return[...new Set(froms)].filter(t=>!ctes.has(t.toLowerCase()))}let _allow=null;async function allowedTables(){if(_allow)return _allow;const c=await ctx(),names=await shortCache(`tables:${c.dataSourceId}`,10*6e4,async()=>[...new Set((await post(`/index-block/get-data-source-table-field/${c.dataSourceId}`)).map(x=>(x.table_name||"").toLowerCase()))]);return _allow=new Set(names),_allow}function formatSql(sql){const lines=sql.split(`
2
+ `).filter(l=>l.trim()),avg=sql.length/Math.max(lines.length,1);if(lines.length>3&&avg<100)return sql;const strs=[];let t=sql.replace(/'(?:[^'\\]|\\.)*'/g,m=>`${strs.push(m)-1}`);t=" "+t.replace(/\s+/g," ")+" ";const KW=["SELECT","FROM","LEFT JOIN","RIGHT JOIN","INNER JOIN","JOIN","WHERE","GROUP BY","HAVING","ORDER BY","LIMIT","UNION ALL","UNION"];for(const k of KW){const guard=k.replace(" ","");t=t.replace(new RegExp(`\\s${k.replace(" ","\\s+")}\\s`,"gi"),`
3
+ ${guard} `)}return t=t.replace(/\u0002/g," "),t=t.replace(/\nSELECT ([^\n]*)/g,(m,body)=>{let out="",depth=0;for(const ch of body)ch==="("&&depth++,ch===")"&&depth--,out+=ch,ch===","&&depth===0&&(out+=`
4
+ `);return`
5
+ SELECT
6
+ `+out.trim()}),t=t.replace(/\u0001(\d+)\u0001/g,(_,i)=>strs[+i]),t.trim()+`
7
+ `}function stripTrailingSemicolon(sql){const masked=sql.replace(/'(?:[^'\\]|\\.)*'/g,m=>" ".repeat(m.length)).replace(/\/\*[\s\S]*?\*\//g,m=>" ".repeat(m.length)).replace(/--[^\n]*/g,m=>" ".repeat(m.length)),idx=[...masked.matchAll(/;/g)].map(m=>m.index);if(!idx.length)return sql;const last=idx[idx.length-1];if(masked.slice(last+1).trim()||idx.length>1)throw new Error('Dataset SQL must be a single SELECT statement (found ";" inside)');return sql.slice(0,last)+sql.slice(last+1)}const SYSTEM_SCHEMAS=/^(information_schema|mysql|performance_schema|sys)\./i;function assertReadOnlySql(sql){const masked=String(sql).replace(/'(?:[^'\\]|\\.)*'/g,"''").replace(/\/\*[\s\S]*?\*\//g," ").replace(/--[^\n]*/g," ").trim();if(!/^(SELECT|WITH)\b/i.test(masked))throw new KindError("SQL_READONLY","Rejected: dataset SQL must be a single read-only SELECT (or WITH … SELECT) query");if(/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(masked))throw new KindError("SQL_READONLY","Rejected: SELECT … INTO OUTFILE/DUMPFILE is not allowed");stripTrailingSemicolon(sql)}function precheckSql(sql){try{assertReadOnlySql(sql)}catch(error){throw logGuard("sql-readonly",!1,{sqlSha256:oplog.sha256(String(sql)),chars:String(sql).length}),error}}async function checkSql(sql){precheckSql(sql),sql=stripTrailingSemicolon(sql);const used=sqlTables(sql),masked=sql.replace(/'(?:[^'\\]|\\.)*'/g,"''").replace(/`/g,""),system=[...new Set([...used.filter(t=>SYSTEM_SCHEMAS.test(t)),...[...masked.matchAll(/\b(information_schema|mysql|performance_schema|sys)\s*\.\s*\w+/gi)].map(m=>m[0])])];if(system.length)throw new KindError("SQL_TABLE_NOT_ALLOWED",`Rejected: system schema access is not allowed (${system.join(", ")})`);if(used.length){const allow=await allowedTables(),bad=used.filter(t=>!allow.has(t.replace(/^[\w]+\./,"").toLowerCase()));if(bad.length)throw new KindError("SQL_TABLE_NOT_ALLOWED",`Rejected: not in the space's table list (${bad.join(", ")}). Only tenant views listed by \`linkai ds-tables\` can be used; to use another view, ask the LinkMore data team to expose it (this CLI cannot operate the database).`)}const c=await ctx();return post("/index-block/get-field-info-by-data-set-sql",{dataSourceId:c.dataSourceId,sql,mode:2})}const CALC_GROUP=99,CALC_SUM=2,fieldColumns=fields=>[...fields].sort((a,b)=>Number(a.sort_num)-Number(b.sort_num)).map((f,i)=>{const num=["int","decimal"].includes(f.field_type);return{id:crypto.randomUUID(),tableFieldName:f.field_name,aliasCnName:f.field_name,fieldComment:"",argumentType:num?0:2,calculationMode:num?CALC_SUM:CALC_GROUP,tableFieldType:num?"decimal":f.field_type,tableFieldTypeName:f.type_name,sortNum:i,sqlType:0,isCustomize:!1}});async function dsSave({id,name,sql,remark,noFormat=!1,current=null}){const c=await ctx(),keep=id&&id!==Z?current||await dsDetail(id):null;!noFormat&&process.env.LM_NO_FMT!=="1"&&(sql=formatSql(sql)),sql=stripTrailingSemicolon(sql);const fields=await checkSql(sql);if(!fields?.length)throw new KindError("SQL_INVALID","运行 SQL 没有返回任何字段,无法生成映射字段");const ts=Date.now(),tables=[{id:crypto.randomUUID(),tableName:`sql_name_${ts}`,tableComment:`sql_comment_${ts}`,sortNum:0,fieldColumns:fieldColumns(fields)}];try{await post("/index-block/get-data-set",{tables,joinOns:[],mode:2,dataSourceId:c.dataSourceId,isNewDataSet:!0,filters:[],sortFields:[],sql})}catch(error){throw error instanceof ApiError?new KindError("SQL_INVALID",`数据集取数验证失败,未保存:${error.message}`):error}return post("/index-block-data-argument/add-or-update",{indexBlockDataId:id||Z,isNewDataSet:!0,mode:2,dataSourceId:c.dataSourceId,sql,name,parentId:keep?.parentId||c.dsFolder,tag:keep?.tag??"",remark:remark??keep?.remark??"",isPrivate:keep?.isPrivate??!1,joinOns:[],tables})}async function dsList({withMeta=!1}={}){const rows=[];for(let pageIndex=1;pageIndex<=200;pageIndex++){const d=await post("/index-block/get-index-block-data-folder-bussiness-info",{page:{pageIndex,pageSize:500}}),items=d.items||[];rows.push(...items.map(x=>({name:x.name,dsId:x.objId,folderId:x.id,...withMeta?{editTime:x.editTime||null,addUser:x.addUser||null}:{}})));const total=Number(d.totalCount??d.total??0);if(items.length<500||total&&rows.length>=total)break}return rows}const dsDetail=dsId=>get(`/index-block-data-argument/data-set-detail/${dsId}`),dsFields=dsId=>get(`/index-block-data-argument/data-set-fields/${dsId}`);async function dsRefs(dsId){return(await get(`/index-block/data-set-quoted-infos/${dsId}`)||[]).map(x=>({name:x.name,cardId:x.indexBlockId}))}function kinds(){return Object.entries(TEMPLATES).sort(([a],[b])=>a.localeCompare(b)).map(([kind,v])=>({kind,name:v._name||"",status:v._disabled?"disabled":v._minimal?"minimal":"full",tip:v._tip||""}))}const ROLE={dim:{prefix:"waxis",axis:"",argumentType:2},meas:{prefix:"yaxis",axis:"y1",argumentType:1},series:{prefix:"waxis_type",axis:"",argumentType:3}};function baseDto({field,col,axis="",argumentType=2,tableFieldType="string",title,orderByType=0,i=0}){return{id:crypto.randomUUID(),format:"default",axis,field,title:title??col,sortNum:i,calculationMode:0,seniorMode:0,periods:0,periodType:0,sqlText:"",comment:col,argumentType,orderByType,tableFieldId:Z,tableFieldGroupId:Z,periodsWithTableFieldId:Z,decimal:0,thousandSeparator:!0,unit:"",tableFieldType,isCustomize:!1,isAggregation:!1,isUseSnow:!1,parent:null,nullZero:!1,isVirtual:!1,isFill:!1,uniqueId:crypto.randomUUID(),isCustomizeFill:!1,isInnerFill:!1,fieldDesc:""}}const orderField=d=>({fieldExpression:"",aliasName:d.title,sortNum:d.sortNum,argumentType:d.argumentType,orderByType:d.orderByType,sqlText:"",tableFieldId:Z,tableFieldName:null,tableFieldComment:d.comment,tableFieldType:d.tableFieldType,isCustomize:!1,fieldExpressionDesc:"",isAggregation:!1,isVirtual:!1,isFill:!1,uniqueId:d.uniqueId,isCustomizeFill:!1,isInnerFill:!1,isNeedTransferJson:!1,jsonFatherId:Z,children:null});async function saveCard({dsId,name,remark,chartParentType,chartType,dtos,chartSetting,limitCount=500,id}){const c=await ctx(),keep=id&&id!==Z?await cardDetail(id):null,payload={indexBlockDataId:dsId,isMulData:!1,indexBlockShowOrderFields:dtos.map(orderField),filters:[],isFreeFill:!1,id:id||Z,indexBlockType:0,name,tag:keep?.tag??"",remark:remark??keep?.remark??"",parentId:keep?.parentId||c.cardFolder,isPrivate:keep?.isPrivate??!1,chartParentType,chartType,limitCount,indexBlockDataSettingDtos:dtos,chartSetting:JSON.stringify(chartSetting)};let cid=id;return(!cid||cid===Z)&&(cid=await post("/index-block/new",payload)),payload.id=cid,await post("/index-block/update-new",payload),{cardId:cid,name,chartType:`${chartParentType}/${chartType}`}}async function datasetMeta(dsId){const fields=await dsFields(dsId);return{fields,meta:Object.fromEntries(fields.map(f=>[f.tableFieldComment,f]))}}async function cardChart({dsId,name,kind,dim,meas,series,id}){const tpl=TEMPLATES[kind];if(!tpl)throw new Error(`Chart kind "${kind}" is not in the v2 chart registry; run: linkai kinds`);if(tpl._disabled)throw new Error(`Chart kind "${kind}" (${tpl._name}) is disabled: ${tpl._disabled}`);const{fields,meta}=await datasetMeta(dsId),dto=(role,n,col,i)=>{const m=meta[col];if(!m)throw new Error(`Dataset has no column "${col}". Available: ${fields.map(f=>f.tableFieldComment).join(" / ")}`);const r=ROLE[role];return baseDto({field:r.prefix+n,col,axis:r.axis,argumentType:r.argumentType,tableFieldType:m.tableFieldType,i})},dims=String(dim||"").split(",").filter(Boolean),measures=String(meas||"").split(",").filter(Boolean);if(!measures.length)throw new Error("At least one measure column is required");const dtos=dims.map((d,i)=>dto("dim",i+1,d,i));return measures.forEach((m,i)=>dtos.push(dto("meas",i+1,m,dtos.length))),series&&dtos.push(dto("series",1,series,dtos.length)),saveCard({dsId,name,chartParentType:tpl.chartParentType,chartType:tpl.chartType,dtos,chartSetting:tpl.chartSetting,limitCount:0,id})}async function cardMeter({dsId,name,col,subtitle,color="#E8611C",id}){const dtos=[baseDto({field:"yaxis1",col,axis:"y1",argumentType:1,tableFieldType:"decimal",title:subtitle||col})];return saveCard({dsId,name,chartParentType:"meter",chartType:"list",dtos,chartSetting:{id:"list",parentId:"meter",config:{group:{type:"value",groups:[]},content:{type:"main",position:"left",bgColor:"lucency",icon:{show:!1,img:"",postion:"left"},labelText:{fontSize:12,fill:"rgb(153,153,153)"},valueText:{fontSize:14,fontWeight:800,fill:"rgba(0,0,0,0.85)"},labelTextMain:{fontSize:12,fill:"rgb(153,153,153)",fontStyle:"italic"},valueTextMain:{fontSize:30,fontWeight:800,fill:color},textPadding:0}}},id})}async function cardTable({dsId,spec,id}){const{meta}=await datasetMeta(dsId),fmap={},dtos=[];let n=0;const reg=col=>{if(fmap[col])return fmap[col];const m=meta[col];if(!m)throw new Error(`Dataset has no column "${col}". Available: ${Object.keys(meta).join(" / ")}`);const f=`waxis${++n}`;return dtos.push(baseDto({field:f,col,argumentType:m.argumentType,tableFieldType:m.tableFieldType,i:n-1})),fmap[col]=f,f},u=()=>crypto.randomUUID(),st=o=>({fontSize:12,fill:"#1F2329",...o}),STYLES={b:st({fontSize:15,fontWeight:600,lineHeight:22}),h:st({fontSize:13,fontWeight:600,lineHeight:20}),grey:st({fill:"#8F959E",lineHeight:18}),"":st({lineHeight:18})},emptyItem=()=>({id:u(),type:"labelValue",field:"",layout:"left",arrangement:"horizontal",fieldStyle:STYLES[""],valueStyle:STYLES[""],showLabel:!1,iconPosition:"left",labelValueGap:4,showColon:!1}),plainColumn=(f,header,hidden)=>({id:"column_"+u(),field:[f],fixed:!1,enableCustomRender:!1,areas:[{id:u(),width:null,rows:[{id:u(),itemGap:10,items:[emptyItem()]}]}],name:header,contentAlign:"left",spanRows:!1,hidden,isPivot:!1,childPivotFields:[],topValueField:!1}),columns=[];for(const c of spec.columns||[]){if(c.plain){columns.push(plainColumn(reg(c.plain),c.header,!1));continue}const usedFields=[],areas=[],mkItem=tok=>{const[col,style=""]=tok.split(":"),f=reg(col);usedFields.push(f);const isImg=style==="image",sty=STYLES[isImg?"":style]||STYLES[""],it={id:u(),type:isImg?"image":"labelValue",field:f,layout:"left",arrangement:"horizontal",fieldStyle:sty,valueStyle:sty,showLabel:!1,iconPosition:"left",labelValueGap:4,showColon:!1};return isImg&&(it.imgWidth=c.imgWidth||112),it};c.image&&areas.push({id:u(),width:(c.imgWidth||112)+16,rows:[{id:u(),itemGap:10,items:[mkItem(`${c.image}:image`)]}]}),areas.push({id:u(),width:null,rows:(c.rows||[]).map(r=>({id:u(),itemGap:10,items:(Array.isArray(r)?r:[r]).map(mkItem)}))}),columns.push({id:"column_"+u(),field:usedFields,fixed:!1,enableCustomRender:!0,areas,name:c.header,contentAlign:"left",spanRows:!1,hidden:!1,isPivot:!1,childPivotFields:[],topValueField:!1})}for(const col of spec.hidden||[])columns.push(plainColumn(reg(col),col,!0));const conds=kind=>(spec[kind]||[]).flatMap(r=>{const dep=fmap[r.dep]||reg(r.dep),tgt=fmap[r.col]||reg(r.col),list=[];for(const[val,color]of Object.entries(r.eq||{}))list.push({field:tgt,color,depField:dep,operator:"==",value:isNaN(+val)?val:+val});for(const[val,color]of Object.entries(r.gte||{}))list.push({field:tgt,color,depField:dep,operator:">=",value:+val});for(const[val,color]of Object.entries(r.ne||{}))list.push({field:tgt,color,depField:dep,operator:"!=",value:val});return list}),rowMark=spec.rowGrey?[{field:"",color:"#F7F8FA",depField:fmap[spec.rowGrey.dep]||reg(spec.rowGrey.dep),operator:">=",value:spec.rowGrey.gte}]:[];spec.order&&reg(spec.order);const chart={id:"agtable",parentId:"table",config:{sheetType:"table",showPagination:!0,pagination:{pageSize:spec.pageSize||20},fitTableWidth:spec.fitTableWidth!==!1,showHeader:!0,headerHeight:36,rowHeight:spec.rowHeight||150,imgHeight:spec.imgHeight||120,showLineNumber:!1,lineNumberTextAlign:"left",actionColTextAlign:"left",cellTextAlign:"left",cellBorder:{visible:!0,color:"#EDEFF2",width:1},total:{show:!1,labelColumn:"",labelText:"总计",rowtotalType:"all"},agColumnWidths:(spec.columns||[]).map(c=>({key:columns.find(x=>x.name===c.header).field[0],width:c.width||200})),conditionStyle:{value:{conditions:conds("pills")},arrow:{conditions:[]},fontColor:{conditions:conds("fontColor")},rowMark:{conditions:rowMark},cellMark:{conditions:[]}},bgMark:{visible:!1,fields:[]},mobileViewFields:[],report:{visible:!1,fillField:{fields:[],fillFieldProperties:[]}},syncReport:{visible:!1,syncConfig:{webHookUrl:"",webHookId:"",relations:[],fields:[]}},wd:{rows:[],values:[],columns}}};return saveCard({dsId,name:spec.name,remark:spec.remark,chartParentType:"table",chartType:"agtable",dtos,chartSetting:chart,limitCount:spec.limitCount||2e3,id})}async function cardGantt({dsId,spec,id}){const{meta}=await datasetMeta(dsId),fmap={},dtos=[];let n=0;const reg=col=>{if(!col)return"";if(fmap[col])return fmap[col];const m=meta[col];if(!m)throw new Error(`Dataset has no column "${col}". Available: ${Object.keys(meta).join(" / ")}`);const f=`waxis${++n}`;return dtos.push(baseDto({field:f,col,argumentType:m.argumentType,tableFieldType:m.tableFieldType,i:n-1})),fmap[col]=f,f},chart={id:"gantt",parentId:"table",config:{ganttRenderConfig:{showIcon:!0,style:{image:reg(spec.image),main:reg(spec.main),assist:reg(spec.assist),status:reg(spec.status),tags:(spec.tags||[]).map(reg),desc:(spec.desc||[]).map(reg),list:(spec.list||[]).map(reg),progress:reg(spec.progress),taskBar:{textFields:(spec.taskBarText||[]).map(reg)},fieldMap:{startDate:reg(spec.start),endDate:reg(spec.end),plannedStart:reg(spec.plannedStart),plannedEnd:reg(spec.plannedEnd)}},dynamic:{main:[],levels:{}}},conditionStyle:{value:{conditions:[]},arrow:{conditions:[]},fontColor:{conditions:[]}},ganttGridTitle:spec.title||"任务",ganttGridMinWidth:452,projectRowHeight:spec.rowHeight||202,defaultZoomLevel:spec.zoom||"day",pagination:{pageSize:spec.pageSize||20},showPagination:!1}};return spec.order&&reg(spec.order),saveCard({dsId,name:spec.name,remark:spec.remark,chartParentType:"table",chartType:"gantt",dtos,chartSetting:chart,id})}async function cardList(){const rows=[];for(let pageIndex=1;pageIndex<=200;pageIndex++){const d=await post("/index-block/get-index-block-folder-bussiness-info",{page:{pageIndex,pageSize:500}}),items=d.items||[];rows.push(...items.map(x=>({name:x.name,cardId:x.objId,folderId:x.id})));const total=Number(d.totalCount??d.total??0);if(items.length<500||total&&rows.length>=total)break}return rows}class TenantMismatchError extends Error{constructor(cardId,tenantName){super(`卡片 ${cardId} 不属于当前租户${tenantName?`「${tenantName}」`:""},已拒绝写入(写进去的数据会落在当前租户名下)`),this.name="TenantMismatchError",this.cardId=cardId}}const TENANT_GUARDED_WRITE=/\/index-block\/(?:update-new|new-fill-field|add-fill-field|fill-field|add-fill-field-by-s-c-m|new-import-fill-data|import-fill-data)(?:\?|$)/i;let _tenantCardIds=null;function tenantGuardTarget(method,p,body){if(String(method).toUpperCase()==="GET"||!TENANT_GUARDED_WRITE.test(String(p))||!body||typeof body!="object"||Array.isArray(body))return"";const pick=(...keys)=>{for(const k of keys){const hit=Object.keys(body).find(x=>x.toLowerCase()===k);if(hit&&body[hit])return String(body[hit])}return""};return/update-new/i.test(p)?pick("id"):pick("usedindexblockid")}async function assertCardInTenant(cardId){if(!cardId)return;const id=String(cardId).toLowerCase(),load=async()=>{_tenantCardIds=new Set((await cardList()).map(x=>String(x.cardId).toLowerCase()))};if(_tenantCardIds?_tenantCardIds.has(id)||await load():await load(),!_tenantCardIds.has(id))throw new TenantMismatchError(cardId,spaceOf(await ensureSession()).tenantName)}const cardDetail=cardId=>post("/index-block/get-new-index-block-detail",{id:cardId});async function cardSort(cardId,spec){let list;if(spec==="clear")list=[];else if(Array.isArray(spec))list=spec;else{const dtos=(await cardDetail(cardId)).indexBlockDataSettingDtos||[];list=String(spec).split(",").map(s=>s.trim()).filter(Boolean).map(s=>{const[col,dir="asc"]=s.split(":"),hit=dtos.find(x=>x.field===col)||dtos.find(x=>x.comment===col);if(!hit)throw new Error(`Card has no field "${col}". Available: ${dtos.map(x=>`${x.field}=${x.comment}`).join(" / ")}`);if(!["asc","desc"].includes(dir.toLowerCase()))throw new Error(`Sort direction must be asc or desc: ${s}`);return{field:hit.field,type:1,sort:dir.toLowerCase(),sortFields:[]}})}const sortField=list.map((x,i)=>({type:1,sortFields:[],...x,sortNum:i}));await api("PUT","/page-setting/page-card-index-block-sort-field",{usedIndexBlockId:cardId,sortField});const q=await queryCard({cardId,pageSize:1});return{cardId,requested:sortField.map(x=>`${x.field} ${x.type===2?"manual":x.sort}`),sqlHasOrderBy:/\bORDER\s+BY\s+(?:W\.)?(?:waxis|yaxis|waxis_type|order)\w*/i.test(q.sql||""),queryCode:q.code}}async function queryCard({cardId,pageSize=20,filters=[]}){const j=await api("POST","/page-setting/get-new-index-block-data",{usedIndexBlockId:cardId,filters,page:{pageIndex:1,pageSize:+pageSize}},{raw:!0}),rows=Array.isArray(j.data)?j.data:[];return{code:j.code,message:j.code===200?"":j.message,total:j.dataCount,rows:rows.length,columns:rows[0]?Object.keys(rows[0]):[],sql:j.extendData?.sql||"",data:rows}}async function planList(){return((await post("/index-block/get-page-setting-folder-bussiness-info",{isOnlySeeSelf:!1,page:{pageIndex:1,pageSize:500}})).items||[]).map(x=>({name:x.name,planId:x.objId,folderId:x.id}))}const planDetail=(planId,isEdit=!0)=>post(`/page-setting/get-new-page-plan/${planId}?isEdit=${isEdit}`,{});async function planAdd({planId,cardId,x=0,y=0,w=24,h=20}){const c=await post("/page-setting/new-page-card",{indexBlockId:cardId,sourceType:0,x:+x,y:+y,width:+w,height:+h,mobileX:0,mobileY:+y,mobileWidth:4,mobileHeight:+h,cardType:1,bindFilterIds:[],userPageSettingId:planId});return{pageCardId:c.id,x:c.x,y:c.y,width:c.width,height:c.height}}async function planFilter(planId,body){if(body.isTenantSystem!==!0)throw new Error("Plan filters must be saved with isTenantSystem:true (tenant template); see references/filters.md");await post("/page-setting/batch-add-or-update-filter",body);const after=await post("/page-setting/reset-user-filter",{userPageSettingId:planId,filterSourceType:4});return{planId,filters:(after||[]).map(f=>f.labelName)}}async function reportList(){return((await post("/index-block/get-index-block-report-folder-bussiness-info",{})).items||[]).map(x=>({name:x.name,cardId:x.indexBlockId,reportId:x.objId,folderId:x.id}))}const reportDetail=cardId=>post("/page-setting/get-new-page-card-report",{indexBlockId:cardId,isSubWarn:!1});async function cleanPrefix(prefix){if(!prefix||!prefix.startsWith("zz-"))throw new Error('clean only accepts prefixes starting with "zz-"');const removed={},del=async(kind,ids)=>{removed[kind]=ids.length,ids.length&&await post("/index-block/delete-folder-bussiness",ids)};return await del("reports",(await reportList()).filter(x=>x.name.startsWith(prefix)).map(x=>x.folderId)),await del("plans",(await planList()).filter(x=>x.name.startsWith(prefix)).map(x=>x.folderId)),await del("cards",(await cardList()).filter(x=>x.name.startsWith(prefix)).map(x=>x.folderId)),await del("datasets",(await dsList()).filter(x=>x.name.startsWith(prefix)).map(x=>x.folderId)),{prefix,removed}}function browserSessionScript(session){return`(() => { const s = ${JSON.stringify({t:session.token,c:session.tenantId,a:session.agentId})};
8
+ localStorage['Access-Token'] = JSON.stringify(s.t);
9
+ localStorage['agentId'] = JSON.stringify(s.a);
10
+ localStorage['LM_COMPANY'] = JSON.stringify([{ id: s.c, name: '-' }]);
11
+ sessionStorage['LM_COMPANY_SELECT'] = JSON.stringify(s.c);
12
+ return true; })()`}function writeJsonFile(file,data){return file=path.resolve(String(file)),fs.mkdirSync(path.dirname(file),{recursive:!0}),fs.writeFileSync(file,typeof data=="string"?data:JSON.stringify(data,null,1)),file}function readInputFile(file){const full=path.resolve(String(file).trim());if(!fs.existsSync(full))throw new KindError("FILE_NOT_FOUND",`文件不存在:${full}(相对路径按当前目录 ${process.cwd()} 解析)`);return fs.readFileSync(full,"utf8")}const KB_API="/linkai-kb",KB_LIBRARIES=Object.freeze({"bi-builder":{pathPrefix:"report-builder",scope:"public",enabled:!0,fallback:"bundled"},"data-dictionary":{pathPrefix:"data-dictionary",scope:"tenant",enabled:!0,fallback:"none"},"integration-flow":{pathPrefix:"integration-flow",scope:"tenant",enabled:!1,fallback:"none"},user:{pathPrefix:"user",scope:"user",enabled:!1,fallback:"none"}});class KbUnavailableError extends Error{constructor(){super("知识库服务暂不可用"),this.name="KbUnavailableError"}}class KbDocumentNotFoundError extends Error{constructor(library,path_){super(`知识库文档不存在:${library}/${path_}`),this.name="KbDocumentNotFoundError"}}const endpointMissing=j=>(j.code===404||j.__status===404)&&/non-JSON/.test(String(j.message||""));function normalizeKbPath(p){const clean=String(p||"").trim().replace(/\\/g,"/").replace(/^\.?\//,"").replace(/^knowledge\//,"");if(!clean||clean.split("/").some(seg=>seg===".."||seg==="")||!clean.endsWith(".md"))throw new Error(`知识库路径不合法:${p}(例:knowledge/report-builder/SOP.md)`);return clean}function kbRequest(p,library){const clean=normalizeKbPath(p);let id=library?String(library):"",relative=clean;const prefixed=Object.entries(KB_LIBRARIES).find(([,v])=>clean===v.pathPrefix||clean.startsWith(`${v.pathPrefix}/`));id||(id=prefixed?.[0]||"bi-builder",prefixed&&(relative=clean.slice(prefixed[1].pathPrefix.length).replace(/^\//,"")));const def=KB_LIBRARIES[id];if(!def)throw new Error(`未知知识库:${id}(支持:${Object.keys(KB_LIBRARIES).join(", ")})`);if(!def.enabled)throw new Error(`知识库 ${id} 已预留,当前版本尚未启用`);if(prefixed&&prefixed[0]!==id)throw new Error(`知识库路径 ${clean} 不属于 ${id}`);if((clean===def.pathPrefix||clean.startsWith(`${def.pathPrefix}/`))&&(relative=clean.slice(def.pathPrefix.length).replace(/^\//,"")),!relative||relative.split("/").some(seg=>seg===".."||seg==="")||!relative.endsWith(".md"))throw new Error(`知识库路径不合法:${p}`);return{library:id,path:relative,canonicalPath:`${def.pathPrefix}/${relative}`,...def}}async function kbCatalog(library="bi-builder"){const def=KB_LIBRARIES[library];if(!def)throw new Error(`未知知识库:${library}`);if(!def.enabled)throw new Error(`知识库 ${library} 已预留,当前版本尚未启用`);if(library==="data-dictionary")throw new Error("数据字典使用结构化只读接口;请执行 linkai kb-sources,再按需使用 kb-search / kb-catalog / kb-object / kb-docs / kb-doc");const j=await api("GET",`${KB_API}/catalog?library=${encodeURIComponent(library)}`,void 0,{raw:!0});if(endpointMissing(j))throw new KbUnavailableError;if(j.code!==200)throw new Error(`读取知识库目录失败:${j.message||j.code}`);return{...j.data||{docs:[]},library,scope:def.scope}}async function kbDoc(p,library){const req=kbRequest(p,library);if(req.library==="data-dictionary")throw new Error("数据字典不再按 Markdown 文件路径读取;请使用 kb-search / kb-object / kb-docs / kb-doc");const j=await api("GET",`${KB_API}/doc?library=${encodeURIComponent(req.library)}&path=${encodeURIComponent(req.path)}`,void 0,{raw:!0});if(endpointMissing(j))throw new KbUnavailableError;if(j.code===404||j.__status===404)throw new KbDocumentNotFoundError(req.library,req.path);if(j.code!==200)throw new Error(j.message||`读取知识库失败:${req.library}/${req.path}`);return{...j.data,library:req.library,scope:req.scope,path:req.canonicalPath}}const AI_KB_API="/ai-kb";class AiKbError extends Error{constructor(code,message){super(message||`数据字典服务返回错误:${code}`),this.name="AiKbError",this.code=Number(code)||500}}function query(params){const q=new URLSearchParams;for(const[key,value]of Object.entries(params))value!=null&&value!==""&&q.set(key,String(value));return q.toString()}async function aiKbRequest(method,endpoint,body){let response;try{response=await api(method,`${AI_KB_API}/${endpoint}`,body,{raw:!0})}catch(error){throw error instanceof LoginRequiredError?error:new AiKbError(503,`数据字典服务暂不可用:${error.message||error}`)}if(endpointMissing(response))throw new AiKbError(503,"数据字典接口尚未部署或当前网关不可用");if(response.code!==200)throw new AiKbError(response.code,response.message);return response.data}const aiKbSources=()=>aiKbRequest("GET","sources");function aiKbCatalog({source,keyword,pageIndex=1,pageSize=30}){return aiKbRequest("GET",`catalog?${query({source,keyword,pageIndex,pageSize})}`)}function aiKbObject({source,db,object}){return aiKbRequest("GET",`object?${query({source,db,object})}`)}function aiKbDocs({source,category,keyword,pageIndex=1,pageSize=30}){return aiKbRequest("GET",`docs?${query({source,category,keyword,pageIndex,pageSize})}`)}function aiKbDocPage({source,doc,offset=0,limit=2e4,expectedVersion}){return aiKbRequest("GET",`doc?${query({source,doc,offset,limit,expectedVersion})}`)}async function aiKbDoc({source,doc,limit=2e4,maxRestarts=3}){for(let attempt=0;attempt<=maxRestarts;attempt++){let offset=0,version,first;const chunks=[];let pages=0;try{for(;;){const page=await aiKbDocPage({source,doc,offset,limit,expectedVersion:version});if(pages++,first||=page,version||=page.contentVersion,page.contentVersion!==version)throw new AiKbError(409,"文档内容版本在分页读取过程中发生变化");if(chunks.push(page.content||""),page.nextOffset===null||page.nextOffset===void 0)return{...first,content:chunks.join(""),contentVersion:version,offset:0,nextOffset:null,totalChars:page.totalChars,truncated:!1,pages};const next=Number(page.nextOffset);if(!Number.isInteger(next)||next<=offset)throw new AiKbError(503,"数据字典服务返回了无效的 nextOffset");offset=next}}catch(error){if(!(error instanceof AiKbError)||error.code!==409||attempt===maxRestarts)throw error}}throw new AiKbError(409,"文档持续变化,无法完成一致性读取")}function aiKbSearch({source,question,topTables=5,topDocs=3}){return aiKbRequest("POST","search",{source,question,topTables,topDocs})}const PLAN_KNOWLEDGE_API=`${KB_API}/plan-knowledge`;class PlanKnowledgeUnavailableError extends Error{constructor(){super("方案知识库服务暂不可用"),this.name="PlanKnowledgeUnavailableError"}}class PlanKnowledgeConflictError extends Error{constructor(message="方案知识库版本冲突,请重新读取当前版本后再更新"){super(message),this.name="PlanKnowledgeConflictError"}}const planKnowledgeUnavailable=response=>endpointMissing(response)||response.code===429||Number(response.code)>=500;async function planKnowledgeGet(planId){let j;try{j=await api("GET",`${PLAN_KNOWLEDGE_API}?planId=${encodeURIComponent(planId)}`,void 0,{raw:!0})}catch(error){throw error instanceof LoginRequiredError?error:new PlanKnowledgeUnavailableError}if(planKnowledgeUnavailable(j))throw new PlanKnowledgeUnavailableError;if(j.code===404)return null;if(j.code!==200)throw new Error(j.message||`读取方案知识库失败:${planId}`);return j.data||null}async function planKnowledgePut(payload){let j;try{j=await api("POST",PLAN_KNOWLEDGE_API,payload,{raw:!0})}catch(error){throw error instanceof LoginRequiredError?error:new PlanKnowledgeUnavailableError}if(planKnowledgeUnavailable(j))throw new PlanKnowledgeUnavailableError;if(j.code===409)throw new PlanKnowledgeConflictError(j.message||void 0);if(j.code!==200)throw new Error(j.message||`保存方案知识库失败:${payload.planId}`);const saved=j.data||{};if(Number(saved.version)!==Number(payload.version))throw new Error(`方案知识库版本异常:请求 v${payload.version},服务器返回 v${saved.version||"?"}`);return saved}export{AiKbError,ApiError,KB_LIBRARIES,KbDocumentNotFoundError,KbUnavailableError,KindError,LoginRequiredError,NetworkError,PlanKnowledgeConflictError,PlanKnowledgeUnavailableError,SITE_ID,TenantMismatchError,Z,aiKbCatalog,aiKbDoc,aiKbDocPage,aiKbDocs,aiKbObject,aiKbSearch,aiKbSources,allowedTables,api,artifactsDir,assertApiAllowed,assertCardInTenant,assertReadOnlySql,browserSessionScript,cardChart,cardDetail,cardGantt,cardList,cardMeter,cardSort,cardTable,checkSql,cleanApiMessage,cleanPrefix,clearSession,clearShortCache,configDir,configFile,ctx,dsDetail,dsFields,dsList,dsRefs,dsSave,ensureSession,fieldColumns,forgetCredentials,formatSql,get,isPlatformTenant,kbCatalog,kbDoc,kbRequest,kinds,logGuard,normalizeKbPath,oauthLogin,oauthLogout,planAdd,planDetail,planFilter,planKnowledgeGet,planKnowledgePut,planList,post,precheckSql,queryCard,readConfig,readInputFile,readSession,reportDetail,reportList,saveSession,setClientVersion,shortCache,spaceOf,sqlTables,stripTrailingSemicolon,switchTenant,tenantGuardTarget,tenantList,tokenPayload,writeJsonFile};
@@ -0,0 +1,2 @@
1
+ import crypto from"node:crypto";import fs from"node:fs";import path from"node:path";import{parseYaml}from"@linkaicli/linkai-cli/utils";import*as core from"./core.js";const isRealId=id=>id&&id!==core.Z,list=value=>Array.isArray(value)?value.map(String).map(x=>x.trim()).filter(Boolean):value?[String(value).trim()]:[],safe=value=>String(value||"").replace(/[\\/:*?"<>|\s]+/g,"_").slice(0,120)||"plan",containsFullSql=value=>/```\s*sql|\bselect\b[\s\S]{0,2000}\bfrom\b/i.test(JSON.stringify(value||{}));function readKnowledgeFile(file){const abs=path.resolve(String(file)),text=fs.readFileSync(abs,"utf8");return/\.json$/i.test(abs)?JSON.parse(text):parseYaml(text)}function validateKnowledgeAuthoring(authoring){const errors=[],need=(value,message)=>{String(value||"").trim()||errors.push(message)};need(authoring?.summary,"knowledge.summary(方案内容)必填"),need(authoring?.problem,"knowledge.problem(解决的问题)必填"),(!Array.isArray(authoring?.cards)||!authoring.cards.length)&&errors.push("knowledge.cards 至少需要一张业务卡片");for(const[i,card]of(authoring?.cards||[]).entries())need(card.cardId,`knowledge.cards[${i}].cardId 必填`),need(card.content,`knowledge.cards[${i}].content(卡片内容)必填`),need(card.source,`knowledge.cards[${i}].source(数据来源)必填`),need(card.grain,`knowledge.cards[${i}].grain(数据粒度)必填`),need(card.dataLogic,`knowledge.cards[${i}].dataLogic(取数逻辑)必填`);return containsFullSql(authoring)&&errors.push("方案知识库不得包含完整 SQL;请改写为自然语言取数逻辑和规则"),errors}function knowledgeAuthoringFromBuild(spec,state){return{summary:spec.knowledge?.summary,problem:spec.knowledge?.problem,rules:list(spec.knowledge?.rules),changeSummary:spec.knowledge?.changeSummary||"首次创建",cards:(spec.cards||[]).map(card=>({cardId:state.cards[card.key],content:card.knowledge?.content,source:card.knowledge?.source,grain:card.knowledge?.grain,dataLogic:card.knowledge?.dataLogic,rules:list(card.knowledge?.rules)}))}}async function readPlanFacts(planId){const config=core.readConfig(),detail=await core.planDetail(planId,!0),cards=[];for(const pc of detail.pageCards||[]){const cardId=pc.indexBlockId||pc.indexBlock?.id;if(!isRealId(cardId))continue;const card=await core.cardDetail(cardId),datasetId=card.indexBlockDataId,dataset=isRealId(datasetId)?await core.dsDetail(datasetId):null;cards.push({pageCardId:pc.id,cardId,name:card.name,chartType:`${card.chartParentType||""}/${card.chartType||""}`.replace(/^\/$/,""),dataset:dataset?{datasetId,name:dataset.name}:null})}return{plan:{planId,name:detail.name,url:`${config.origin}/view/plan?plan=${planId}`},cards}}function assembleDocument(facts,authoring,{operation,baseline=!1}={}){const byId=new Map((authoring.cards||[]).map(card=>[String(card.cardId),card])),onlineIds=new Set(facts.cards.map(card=>String(card.cardId))),missing=facts.cards.filter(card=>!byId.has(String(card.cardId))).map(card=>`${card.name}(${card.cardId})`),extra=[...byId.keys()].filter(id=>!onlineIds.has(id));if(missing.length||extra.length){const errors=[];throw missing.length&&errors.push(`以下线上业务卡片缺少知识段落:${missing.join("、")}`),extra.length&&errors.push(`知识文件包含不在方案中的卡片:${extra.join("、")}`),new core.KindError("KNOWLEDGE_INVALID",errors.join(";"))}return{schemaVersion:1,name:`${facts.plan.name}方案知识库`,plan:facts.plan,operation,baseline,summary:String(authoring.summary).trim(),problem:String(authoring.problem).trim(),rules:list(authoring.rules),changeSummary:String(authoring.changeSummary||(operation==="create"?"首次创建":"方案更新")).trim(),cards:facts.cards.map((fact,index)=>{const written=byId.get(String(fact.cardId));return{order:index+1,...fact,content:String(written.content).trim(),source:String(written.source).trim(),grain:String(written.grain).trim(),dataLogic:String(written.dataLogic).trim(),rules:list(written.rules)}})}}function renderPlanKnowledge(document,version){const L=[];if(L.push(`# ${document.name}`,""),L.push(`- 版本:v${version}`),L.push(`- 方案名称:${document.plan.name}`),L.push(`- 方案 ID:${document.plan.planId}`),L.push(`- 方案地址:${document.plan.url}`,""),L.push("## 方案说明","","### 方案内容","",document.summary,"","### 解决的问题","",document.problem,""),L.push("### 公共规则",""),document.rules.length)for(const rule of document.rules)L.push(`- ${rule}`);else L.push("(无额外公共规则)");L.push("","## 卡片与数据","");for(const card of document.cards){if(L.push(`## ${String(card.order).padStart(2,"0")} ${card.name}`,""),L.push("### 卡片内容","",`- 卡片 ID:${card.cardId}`,`- 图表类型:${card.chartType||"未标注"}`,"",card.content,""),L.push("### 数据集",""),L.push(`- 数据集名称:${card.dataset?.name||"(无独立数据集)"}`),L.push(`- 数据集 ID:${card.dataset?.datasetId||"(无)"}`),L.push(`- 数据来源:${card.source}`),L.push(`- 数据粒度:${card.grain}`,""),L.push("### 取数逻辑与规则","",card.dataLogic,""),card.rules.length)for(const rule of card.rules)L.push(`- ${rule}`);L.push("")}return L.push("## 版本记录","",`- v${version}:${document.baseline?"历史方案首次纳管;":""}${document.changeSummary}`,""),L.join(`
2
+ `)}function contentHash(document){const{operation:_operation,baseline:_baseline,...semantic}=document;return crypto.createHash("sha256").update(JSON.stringify(semantic)).digest("hex")}function localPlanDir(planId){const tenantId=core.readSession()?.tenantId||"unknown-tenant";return core.artifactsDir("plan-knowledge",safe(tenantId),safe(planId))}function pendingFiles(planId){const dir=localPlanDir(planId);return fs.existsSync(dir)?fs.readdirSync(dir).filter(name=>/^v\d+\.pending\.json$/.test(name)).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))).map(name=>path.join(dir,name)):[]}function localMarkdownFiles(planId){const dir=localPlanDir(planId);return fs.existsSync(dir)?fs.readdirSync(dir).filter(name=>/^v\d+\.md$/.test(name)).sort((a,b)=>Number(a.slice(1))-Number(b.slice(1))).map(name=>path.join(dir,name)):[]}function highestLocalVersion(planId){const versions=[...pendingFiles(planId),...localMarkdownFiles(planId)].map(file=>Number(path.basename(file).match(/^v(\d+)/)?.[1]||0));return Math.max(0,...versions)}function latestPending(planId){const file=pendingFiles(planId).at(-1);if(!file)return null;try{return{file,...JSON.parse(fs.readFileSync(file,"utf8"))}}catch{return null}}async function readCurrentPlanKnowledge(planId){try{const current=await core.planKnowledgeGet(planId);return current?{planId,exists:!0,version:`v${Number(current.version)||1}`,content:current.markdown||renderPlanKnowledge(current.document,Number(current.version)||1)}:{planId,exists:!1}}catch(error){if(!(error instanceof core.PlanKnowledgeUnavailableError))throw error;const markdown=localMarkdownFiles(planId).at(-1);if(!markdown)return{planId,exists:!1,next:"当前没有可读取的方案知识库:照常完成修改,最后用 plan-knowledge --mode update 同步,CLI 会自动补建 v1 基线"};const version=Number(path.basename(markdown).match(/^v(\d+)/)?.[1]||1);return{planId,exists:!0,version:`v${version}`,content:fs.readFileSync(markdown,"utf8")}}}function savePending(planId,version,payload,markdown){const dir=localPlanDir(planId);fs.mkdirSync(dir,{recursive:!0,mode:448});const stem=path.join(dir,`v${version}`);fs.writeFileSync(`${stem}.pending.json`,JSON.stringify({payload,createdAt:new Date().toISOString()},null,2),{mode:384}),fs.writeFileSync(`${stem}.md`,markdown,{mode:384})}async function syncPending(planId){for(const file of pendingFiles(planId)){let pending;try{pending=JSON.parse(fs.readFileSync(file,"utf8"))}catch{continue}try{await core.planKnowledgePut(pending.payload),fs.rmSync(file)}catch(error){if(error instanceof core.PlanKnowledgeUnavailableError)return;if(error instanceof core.PlanKnowledgeConflictError){fs.renameSync(file,file.replace(/\.pending\.json$/,".conflict.json"));continue}return}}}async function publishPlanKnowledge({planId,operation,authoring}){if(!["create","update"].includes(operation))throw new Error("operation 必须是 create 或 update;不明确时先询问用户");const authoringErrors=validateKnowledgeAuthoring(authoring);if(operation==="update"&&!String(authoring?.changeSummary||"").trim()&&authoringErrors.push("修改方案时 knowledge.changeSummary(本次修改说明)必填"),authoringErrors.length)throw new core.KindError("KNOWLEDGE_INVALID",authoringErrors.join(";"));await syncPending(planId);const facts=await readPlanFacts(planId);let existing=null,unavailable=!1;try{existing=await core.planKnowledgeGet(planId)}catch(error){if(!(error instanceof core.PlanKnowledgeUnavailableError))throw error;unavailable=!0}const document=assembleDocument(facts,authoring,{operation,baseline:operation==="update"&&!existing}),hash=contentHash(document);if(existing?.contentHash===hash){const version2=Number(existing.version)||1,current=existing.document||document;return{plan:facts.plan,planKnowledge:{name:current.name,version:`v${version2}`,content:existing.markdown||renderPlanKnowledge(current,version2)}}}const pending=existing?null:latestPending(planId);if(pending?.payload?.contentHash===hash){const version2=Number(pending.payload.version)||1,current=pending.payload.document||document;return{plan:facts.plan,planKnowledge:{name:current.name,version:`v${version2}`,content:pending.payload.markdown||renderPlanKnowledge(current,version2)}}}if(operation==="create"&&existing)throw new Error(`方案 ${planId} 已有方案知识库,请使用 update`);const baseVersion=Math.max(Number(existing?.version)||0,highestLocalVersion(planId));let version=baseVersion+1,markdown=renderPlanKnowledge(document,version);const payload={planId,operation,expectedVersion:baseVersion,version,contentHash:hash,document,markdown};if(!unavailable)try{const saved=await core.planKnowledgePut(payload);version=Number(saved?.version)||version,markdown=renderPlanKnowledge(document,version)}catch(error){if(!(error instanceof core.PlanKnowledgeUnavailableError))throw error;unavailable=!0}return unavailable&&savePending(planId,version,payload,markdown),{plan:facts.plan,planKnowledge:{name:document.name,version:`v${version}`,content:markdown}}}export{containsFullSql,knowledgeAuthoringFromBuild,publishPlanKnowledge,readCurrentPlanKnowledge,readKnowledgeFile,renderPlanKnowledge,validateKnowledgeAuthoring};
@@ -0,0 +1 @@
1
+ import fs from"node:fs";import path from"node:path";import os from"node:os";const MAX_AGE_OUTSIDE_FLOW_MS=2*3600*1e3,file=()=>path.join(os.homedir(),".linkai","cache","dict-reads.json");function readState(){try{return JSON.parse(fs.readFileSync(file(),"utf8"))||{}}catch{return{}}}function writeState(state){try{fs.mkdirSync(path.dirname(file()),{recursive:!0,mode:448}),fs.writeFileSync(file(),JSON.stringify(state),{mode:384})}catch{}}function recordRead({tenantId,source,flowId=null,op,now=Date.now()}){if(!tenantId||!source)return;const state=readState();state.reads??={},state.reads[tenantId]??={},state.reads[tenantId][String(source).toLowerCase()]={readAt:now,flowId,op},writeState(state)}function resetReads(reason,now=Date.now()){writeState({reads:{},resetAt:now,resetReason:reason})}function parseServerTime(text){if(!text)return null;const t=Date.parse(`${String(text).trim().replace(" ","T")}+08:00`);return Number.isFinite(t)?t:null}const fmt=ms=>new Date(ms+8*3600*1e3).toISOString().slice(0,19).replace("T"," ");async function dictionaryStatus({tenantId,source,flowId,fetchSources,fetchDocs,now=Date.now()}){let sources;try{sources=await fetchSources()}catch(e){return{ok:!0,skipped:`数据字典服务暂不可用(${String(e?.message||e).slice(0,80)}),本次未校验`}}const s=(sources||[]).find(x=>String(x.source).toLowerCase()===String(source).toLowerCase());if(!s||s.metadataReady===!1)return{ok:!0,skipped:"当前数据集所用数据源没有可用的数据字典,本次未校验"};let docs=[];try{docs=await fetchDocs()||[]}catch{}const updated=[parseServerTime(s.lastSuccessAt),...docs.map(d=>parseServerTime(d.updatedAt))].filter(Number.isFinite),dictUpdatedAt=updated.length?Math.max(...updated):null,rec=readState().reads?.[tenantId]?.[String(source).toLowerCase()],base={source,sourceName:s.name??null,dictUpdatedAt:dictUpdatedAt?fmt(dictUpdatedAt):null};if(!rec)return{...base,ok:!1,reason:"当前租户还没有读取数据字典(切换租户或重新登录后必须重新读取)"};const readAt=fmt(rec.readAt);return dictUpdatedAt&&rec.readAt<dictUpdatedAt?{...base,readAt,ok:!1,reason:`数据字典在 ${fmt(dictUpdatedAt)} 更新过,晚于上次读取(${readAt}),必须重新读取`}:rec.flowId!==flowId&&now-rec.readAt>MAX_AGE_OUTSIDE_FLOW_MS?{...base,readAt,ok:!1,reason:`上次读取数据字典是 ${readAt},不在当前任务内且已超过 2 小时,必须重新读取`}:{...base,readAt,ok:!0}}function readHint(source){return`先读取当前租户的最新数据字典:linkai kb-sources 确认数据源,再按需 linkai kb-search --source ${source} --question "<业务问题>"、kb-object / kb-docs / kb-doc 核对本次 SQL 用到的视图、字段与口径,然后重新执行。不能沿用其它租户或旧的数据字典结论。`}export{MAX_AGE_OUTSIDE_FLOW_MS,dictionaryStatus,parseServerTime,readHint,recordRead,resetReads};
@@ -0,0 +1 @@
1
+ const FILL_TYPES=Object.freeze({varchar:"string",int:"int",decimal:"decimal",datetime:"datetime",date:"date",bool:"bool",img:"string",file:"string",btn:"string"});function properties(value){return value?Array.isArray(value)?value.flatMap(properties):typeof value!="object"?[]:Array.isArray(value.fillFieldProperties)?value.fillFieldProperties:Array.isArray(value.fillFields)?properties(value.fillFields):[]:[]}function chartReport(value){if(!value)return null;if(typeof value=="object")return value.config?.report;if(typeof value!="string")return null;try{return JSON.parse(value).config?.report}catch{return null}}function validateFillCardPayload(method,path,body){if(String(method).toUpperCase()!=="POST"||!/(?:^|\/)index-block\/(?:new|update-new)(?:\?|$)/i.test(String(path))||!body||typeof body!="object"||Array.isArray(body))return;const report=chartReport(body.chartSetting),topProperties=properties([body.fillFields,body.fillField]),mirrorProperties=properties([report?.fillFields,report?.fillField]),allProperties=[...topProperties,...mirrorProperties],left=Array.isArray(body.indexBlockShowOrderFields)?body.indexBlockShowOrderFields:[],right=Array.isArray(body.indexBlockDataSettingDtos)?body.indexBlockDataSettingDtos:[];if(!allProperties.length&&!left.some(field=>field?.isFill)&&!right.some(field=>field?.isFill))return;if(!topProperties.length)throw new Error("填报卡片缺少顶层 fillFields[].fillFieldProperties 定义");const byId=new Map;for(const property of allProperties){const name=String(property?.fillField||"").trim(),type=property?.fillFieldType;if(!name||!Object.hasOwn(FILL_TYPES,type))throw new Error(`填报字段「${name||"未命名"}」的 fillFieldType 无效;字符必须用 varchar,不能用 string`);if(property.tableFieldType&&property.tableFieldType!==FILL_TYPES[type])throw new Error(`填报字段「${name}」的 tableFieldType 与 fillFieldType 不匹配`);if(!property.uniqueId)throw new Error(`填报字段「${name}」缺少 uniqueId`);const previous=byId.get(property.uniqueId);if(previous&&(previous.fillField!==name||previous.fillFieldType!==type))throw new Error(`填报字段「${name}」在多个配置位置的名称或类型不一致`);byId.set(property.uniqueId,{fillField:name,fillFieldType:type})}for(const field of left.filter(item=>item?.isFill)){const source=byId.get(field.uniqueId);if(!source)throw new Error("左侧填报字段缺少同 uniqueId 的填报属性定义");const name=source.fillField,expression=`{${name}}`;if(!name||field.argumentType!==4||field.isCustomize!==!0||field.fieldExpression!==expression||field.fieldExpressionDesc!==expression)throw new Error(`左侧填报字段「${name||"未命名"}」必须是 argumentType:4、isCustomize:true,表达式为 {字段名}`);if(field.tableFieldType!==FILL_TYPES[source.fillFieldType])throw new Error(`左侧填报字段「${name}」的 tableFieldType 与填报类型不匹配`)}for(const[id,source]of byId)if(!left.some(field=>field?.isFill&&field.uniqueId===id))throw new Error(`填报字段「${source.fillField}」缺少同 uniqueId 的左侧填报定义`);for(const field of right.filter(item=>item?.isFill)){if(field.argumentType!==2)throw new Error(`右侧绘制区的填报字段「${field.title||field.comment||""}」必须保持 argumentType:2`);if(!left.some(item=>item?.isFill&&item.uniqueId===field.uniqueId))throw new Error("右侧绘制区的填报字段缺少同 uniqueId 的左侧填报定义")}}function nameOf(item){return typeof item=="string"?item.trim():String(item?.fillField||item?.field||item?.title||item?.name||"").trim()}function fillBlockOf(detail,key="fillfieldid"){if(!/^fillfieldid\d*$/.test(key))throw new Error(`key 只能是 fillfieldid、fillfieldid1…(选择填报块),收到:${key}`);const sortNum=Number(key.slice(11)||0),blocks=Array.isArray(detail?.fillFields)?detail.fillFields:[],block=blocks.find(b=>Number(b?.sortNum)===sortNum)||blocks[sortNum],fields=properties(block).map(nameOf).filter(Boolean);if(!fields.length)throw new Error(`卡片没有 ${key} 对应的填报块,或填报块没有字段`);const report=chartReport(detail?.chartSetting),blockIndex=blocks.indexOf(block),asList=(v,perBlock)=>{if(Array.isArray(v))return v;if(typeof v!="string"||!v.trim())return[];try{const j=JSON.parse(v);return Array.isArray(j)?j:[j]}catch{}return(perBlock&&v.includes(";")?v.split(";")[blockIndex]??"":v).split(/[,,]/).map(x=>x.trim()).filter(Boolean)},unique=[asList(block?.freeFillUniqueField,!1),asList(block?.freeFillUniqueFields,!1),asList(detail?.freeFillUniqueFields,!0),asList(report?.freeFillUniqueFields,!0)].find(v=>v.length)||[],byId=new Map(properties(block).map(p=>[String(p?.uniqueId||""),nameOf(p)])),uniqueFields=unique.map(x=>typeof x=="object"&&x?.uniqueId&&byId.get(String(x.uniqueId))||byId.get(String(x).trim())||nameOf(x)).filter(Boolean),unknown=uniqueFields.filter(x=>!fields.includes(x));if(unknown.length)throw new Error(`无法识别卡片的依据字段(${unknown.join("、")});请先回读页面配置确认,不要猜测`);return{key,fields,uniqueFields}}const cell=v=>v==null?"":typeof v=="object"?JSON.stringify(v):String(v);function buildFillValues(block,records){if(!Array.isArray(records)||!records.length)throw new Error("records 必须是非空 JSON 数组,每个元素是一条业务记录");const known=new Set(block.fields),seen=new Map;return records.map((record,i)=>{const row=`第 ${i+1} 条记录`;if(!record||typeof record!="object"||Array.isArray(record))throw new Error(`${row}不是对象`);const{_id,...values}=record,unknown=Object.keys(values).filter(k=>!known.has(k));if(unknown.length)throw new Error(`${row}含有填报块里没有的字段:${unknown.join("、")}(可用:${block.fields.join("、")})`);const missing=block.fields.filter(k=>!Object.hasOwn(values,k));if(missing.length)throw new Error(`${row}缺少字段:${missing.join("、")}。一条记录必须一次带齐全部填报字段,禁止拆字段写`);if(!_id&&!block.uniqueFields.length)throw new Error(`${row}没有 _id,且卡片未配置“填报内容依据的字段”:无法安全新增(不能自造记录ID)。请在页面上新增,或先配置依据字段`);if(block.uniqueFields.length){const k=block.uniqueFields.map(f=>cell(values[f]).trim());if(k.some(v=>!v))throw new Error(`${row}的依据字段(${block.uniqueFields.join("、")})不能为空`);const sig=JSON.stringify(k);if(seen.has(sig))throw new Error(`${row}与第 ${seen.get(sig)+1} 条的依据字段重复:${k.join(" / ")}`);seen.set(sig,i)}const fillFieldValue=JSON.stringify(Object.fromEntries(block.fields.map(f=>[f,cell(values[f])])));return{key:block.key,..._id?{value:String(_id)}:{},fillFieldValue}})}export{buildFillValues,fillBlockOf,validateFillCardPayload};
@@ -0,0 +1,8 @@
1
+ import crypto from"node:crypto";import{spawn}from"node:child_process";const CLIENT_ID="linkai-cli",SCOPE="linkai offline_access",base64url=bytes=>Buffer.from(bytes).toString("base64url"),randomValue=()=>base64url(crypto.randomBytes(32));function endpoint(value,expectedOrigin,name){let url;try{url=new URL(value)}catch{throw new Error(`OAuth 发现文档缺少有效的 ${name}`)}if(url.origin!==expectedOrigin||!["http:","https:"].includes(url.protocol)||url.username||url.password||url.hash)throw new Error(`OAuth 发现文档的 ${name} 地址与领谋配置不匹配`);return url}async function oauthMetadata(config){const apiOrigin=new URL(config.apiBase).origin,response=await fetch(`${config.apiBase}/.well-known/oauth-authorization-server`,{headers:{Accept:"application/json"},signal:AbortSignal.timeout(15e3)});if(!response.ok)throw new Error(`领谋 OAuth 接口暂不可用(HTTP ${response.status}),请确认后端已部署`);const data=await response.json().catch(()=>null);if(!data||data.issuer!==config.apiBase||!Array.isArray(data.code_challenge_methods_supported)||!data.code_challenge_methods_supported.includes("S256")||!Array.isArray(data.grant_types_supported)||!data.grant_types_supported.includes("authorization_code")||!data.grant_types_supported.includes("refresh_token"))throw new Error("领谋 OAuth 发现文档不符合 CLI 的授权码与 PKCE 要求");const authorization=endpoint(data.authorization_endpoint,config.origin,"authorization_endpoint"),token=endpoint(data.token_endpoint,apiOrigin,"token_endpoint"),revocation=endpoint(data.revocation_endpoint,apiOrigin,"revocation_endpoint"),online=data.cli_session_endpoint?endpoint(data.cli_session_endpoint,apiOrigin,"cli_session_endpoint"):null;if(token.pathname!=="/oauth/token"||revocation.pathname!=="/oauth/revoke")throw new Error("领谋 OAuth 发现文档中的令牌接口路径不符合预期");if(online&&(online.pathname!=="/oauth/cli/sessions"||online.search))throw new Error("领谋线上授权会话接口路径不符合预期");return{authorization,token,revocation,online}}function openSystemBrowser(url){const[command,args]=process.platform==="darwin"?["open",[url]]:process.platform==="win32"?["rundll32.exe",["url.dll,FileProtocolHandler",url]]:["xdg-open",[url]];return new Promise(resolve=>{const child=spawn(command,args,{stdio:"ignore",detached:!0});child.once("error",()=>resolve(!1)),child.once("spawn",()=>{child.unref(),resolve(!0)})})}async function oauthForm(url,fields){let response;try{response=await fetch(url,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Accept:"application/json"},body:new URLSearchParams(fields),signal:AbortSignal.timeout(15e3)})}catch{const error=new Error("领谋 OAuth 请求结果无法确认,请重新授权");throw error.code="oauth_exchange_unknown",error}const data=await response.json().catch(()=>null);if(!response.ok){const code=typeof data?.error=="string"?data.error:`HTTP_${response.status}`,error=new Error(`领谋 OAuth 请求失败:${code}`);throw error.code=response.status>=500?"oauth_exchange_unknown":code,error}return data}async function onlineRequest(url,body,timeoutMs=15e3){const response=await fetch(url,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(body),signal:AbortSignal.timeout(timeoutMs)}),data=await response.json().catch(()=>null);if(!response.ok){const error=new Error(`领谋线上授权请求失败:${data?.error||`HTTP_${response.status}`}`);throw error.code=data?.error||"oauth_online_error",error}return data}async function waitForOnlineApproval(response,state){if(!response.ok||!response.body)throw new Error(`领谋线上授权事件连接失败(HTTP ${response.status})`);const reader=response.body.getReader(),decoder=new TextDecoder;let buffer="";try{for(;;){const{value,done}=await reader.read();if(done)throw new Error("领谋线上授权事件连接已断开,请重新执行 linkai login");if(buffer=(buffer+decoder.decode(value,{stream:!0})).replace(/\r\n/g,`
2
+ `),buffer.length>65536)throw new Error("领谋线上授权事件无效");let boundary;for(;(boundary=buffer.indexOf(`
3
+
4
+ `))!==-1;){const block=buffer.slice(0,boundary);if(buffer=buffer.slice(boundary+2),!block.split(`
5
+ `).some(line=>line==="event: authorization"))continue;const dataLine=block.split(`
6
+ `).find(line=>line.startsWith("data: "));let event;try{event=JSON.parse(dataLine?.slice(6))}catch{throw new Error("领谋线上授权事件无效")}if(event?.state!==state)throw new Error("领谋线上授权事件 state 不匹配");if(event.status==="approved")return;throw event.status==="expired"?new Error("等待领谋 OAuth 授权超时,请重新执行 linkai login"):new Error("领谋线上授权未完成,请重新执行 linkai login")}}}finally{await reader.cancel().catch(()=>{})}}function tokenSession(data,agentId){if(!data||typeof data.access_token!="string"||typeof data.refresh_token!="string"||typeof data.tenant_id!="string"||data.token_type?.toLowerCase()!=="bearer")throw new Error("领谋 OAuth 令牌响应缺少必要字段");return{token:data.access_token,refreshToken:data.refresh_token,tenantId:data.tenant_id,agentId,authMode:"oauth"}}async function refreshOauthSession(config,session){const{token}=await oauthMetadata(config),data=await oauthForm(token,{grant_type:"refresh_token",client_id:CLIENT_ID,refresh_token:session.refreshToken});try{return tokenSession(data,session.agentId)}catch{const error=new Error("领谋 OAuth 续期响应不完整,请重新授权");throw error.code="oauth_exchange_unknown",error}}async function revokeOauthSession(config,session){if(!session?.refreshToken)return!1;const{revocation}=await oauthMetadata(config);return await oauthForm(revocation,{client_id:CLIENT_ID,token:session.refreshToken}),!0}async function authorizeTenantSession(config,session,tenantId){const metadata=await oauthMetadata(config),state=randomValue(),verifier=randomValue(),challenge=base64url(crypto.createHash("sha256").update(verifier,"ascii").digest()),redirectUri="http://127.0.0.1:49152/callback",response=await fetch(new URL("/oauth/authorize",config.apiBase),{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json",Authorization:`Bearer ${session.token}`,Agentid:session.agentId,Tenantid:session.tenantId},body:JSON.stringify({response_type:"code",client_id:CLIENT_ID,redirect_uri:redirectUri,scope:SCOPE,state,code_challenge:challenge,code_challenge_method:"S256",agent_id:session.agentId,tenant_id:tenantId}),signal:AbortSignal.timeout(15e3)}),result=await response.json().catch(()=>null);if(!response.ok||typeof result?.redirect_uri!="string")throw new Error(`切换租户授权失败:${result?.error||`HTTP_${response.status}`}`);const redirect=new URL(result.redirect_uri),expected=new URL(redirectUri);if(redirect.origin!==expected.origin||redirect.pathname!==expected.pathname||redirect.searchParams.getAll("state").length!==1||redirect.searchParams.get("state")!==state||redirect.searchParams.getAll("code").length!==1||!redirect.searchParams.get("code"))throw new Error("切换租户授权回调不符合预期");const data=await oauthForm(metadata.token,{grant_type:"authorization_code",client_id:CLIENT_ID,code:redirect.searchParams.get("code"),redirect_uri:redirectUri,code_verifier:verifier}),next=tokenSession(data,session.agentId);if(next.tenantId.toLowerCase()!==String(tenantId).toLowerCase())throw await revokeOauthSession(config,next).catch(()=>{}),new Error("切换租户后令牌的租户与目标不一致");return next}async function loginWithOauth(config,{agentId,tenant,switchAccount=!1,timeoutSeconds=300,browser=openSystemBrowser,onAuthorized=async session=>({session,tenantName:session.tenantId})}={}){const metadata=await oauthMetadata(config);if(!metadata.online)throw new Error("领谋线上授权会话接口尚未部署,请稍后重试");const state=randomValue(),verifier=randomValue(),challenge=base64url(crypto.createHash("sha256").update(verifier,"ascii").digest()),device=agentId||crypto.randomUUID().replace(/-/g,"");let online=null,issuedSession=null,saved=!1,streamAbort=null,timer=null;try{if(online=await onlineRequest(metadata.online,{client_id:CLIENT_ID,scope:SCOPE,state,code_challenge:challenge,code_challenge_method:"S256",agent_id:device,timeout_seconds:timeoutSeconds}),!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(online?.session_id)||!/^[A-Za-z0-9_-]{43}$/.test(online?.client_secret)||!/^[0-9A-F]{4}-[0-9A-F]{4}$/.test(online?.verification_code))throw new Error("领谋线上授权会话响应不完整");const url=endpoint(online.authorization_url,config.origin,"authorization_url");if(url.pathname!==metadata.authorization.pathname||url.searchParams.get("session_id")!==online.session_id)throw new Error("领谋线上授权页地址无效");tenant&&url.searchParams.set("tenant",tenant),switchAccount&&url.searchParams.set("prompt","login");const sessionUrl=`${metadata.online.href}/${online.session_id}`;streamAbort=new AbortController,timer=setTimeout(()=>streamAbort.abort(),timeoutSeconds*1e3),timer.unref();const stream=await fetch(`${sessionUrl}/events`,{headers:{Accept:"text/event-stream","X-Cli-Session":online.client_secret},signal:streamAbort.signal});process.stderr.write(`请在浏览器中完成领谋授权:${url.href}
7
+ 请核对页面中的授权码:${online.verification_code}
8
+ `),await browser(url.href),await waitForOnlineApproval(stream,state);const data=await onlineRequest(`${sessionUrl}/token`,{client_secret:online.client_secret,code_verifier:verifier});issuedSession=tokenSession(data,device);const result=await onAuthorized(issuedSession);saved=!0;let acknowledged=!1;for(let attempt=0;attempt<2&&!acknowledged;attempt++)try{acknowledged=(await onlineRequest(`${sessionUrl}/complete`,{client_secret:online.client_secret,success:!0},5e3))?.status==="completed"}catch{}if(!acknowledged)throw new Error("CLI 已保存授权,但网页确认通知失败;请执行 linkai status 核对登录状态");return result.session}catch(error){if(issuedSession&&!saved&&await revokeOauthSession(config,issuedSession).catch(()=>{}),online?.session_id&&online?.client_secret&&!saved){const sessionUrl=`${metadata.online.href}/${online.session_id}`;await onlineRequest(`${sessionUrl}/complete`,{client_secret:online.client_secret,success:!1}).catch(()=>{})}throw!saved&&streamAbort?.signal.aborted?new Error("等待领谋 OAuth 授权超时,请重新执行 linkai login"):error}finally{timer&&clearTimeout(timer),streamAbort?.abort()}}export{authorizeTenantSession,loginWithOauth,oauthMetadata,openSystemBrowser,refreshOauthSession,revokeOauthSession};
@@ -0,0 +1 @@
1
+ import fs from"node:fs";import path from"node:path";import os from"node:os";import crypto from"node:crypto";import*as core from"./core.js";const SIMILAR_THRESHOLD=.75,TRIAL=/^zz-/i,sha=s=>crypto.createHash("sha256").update(s).digest("hex");function normalizeSql(sql){return String(sql||"").replace(/\/\*[\s\S]*?\*\//g," ").replace(/--[^\n]*/g," ").split(/('(?:[^'\\]|\\.)*')/).map((p,i)=>i%2?p:p.replace(/`/g,"").toLowerCase()).join("").replace(/\s+/g," ").replace(/\s*([(),=<>!+\-*/;|])\s*/g,"$1").replace(/;+$/,"").trim()}function sqlFingerprint(sql){return sha(normalizeSql(sql)).slice(0,32)}function tokens(normalized){return normalized.match(/'(?:[^'\\]|\\.)*'|[a-z_一-龥][\w一-龥.]*|\d+(?:\.\d+)?|[^\s\w]/g)||[]}function sqlShingles(sql){const t=tokens(normalizeSql(sql)),out=new Set;for(let i=0;i<t.length-1;i++)out.add(sha(`${t[i]} ${t[i+1]}`).slice(0,8));return[...out]}function similarity(a,b){const A=new Set(a),B=new Set(b);if(!A.size&&!B.size)return 1;let inter=0;for(const x of A)B.has(x)&&inter++;return inter/(A.size+B.size-inter)}function sameSql(input,saved){const target=sqlFingerprint(saved);if(sqlFingerprint(input)===target)return!0;try{return sqlFingerprint(core.stripTrailingSemicolon(core.formatSql(input)))===target}catch{return!1}}const tableSet=sql=>[...new Set(core.sqlTables(core.stripTrailingSemicolon(String(sql||""))).map(t=>t.replace(/^[\w]+\./,"").toLowerCase()))].sort(),sameSet=(a,b)=>a.length===b.length&&a.every((x,i)=>x===b[i]);function cacheFile(tenantId){const dir=path.join(os.homedir(),".linkai","cache");return fs.mkdirSync(dir,{recursive:!0,mode:448}),path.join(dir,`ds-index-${String(tenantId).replace(/[^\w-]/g,"")}.json`)}async function mapLimit(items,limit,fn){const out=new Array(items.length);let next=0;return await Promise.all(Array.from({length:Math.min(limit,items.length)},async()=>{for(;next<items.length;){const i=next++;out[i]=await fn(items[i],i)}})),out}async function datasetIndex({concurrency=12}={}){const session=await core.ensureSession(),file=cacheFile(session.tenantId);let cache={};try{cache=JSON.parse(fs.readFileSync(file,"utf8"))||{}}catch{}const list2=await core.dsList({withMeta:!0}),entries=await mapLimit(list2,concurrency,async d=>{const hit=cache[d.dsId];if(hit&&hit.editTime===d.editTime)return{...hit,name:d.name};let detail;try{detail=await core.dsDetail(d.dsId)}catch(e){if(e instanceof core.LoginRequiredError||e instanceof core.NetworkError)throw e;return{dsId:d.dsId,name:d.name,editTime:d.editTime,addUser:d.addUser||null,unreadable:!0}}const columns=(detail.tables?.[0]?.fieldColumns||[]).map(c=>c.aliasCnName||c.tableFieldName).filter(Boolean);return{dsId:d.dsId,name:d.name,editTime:d.editTime,addUser:d.addUser||null,hash:sqlFingerprint(detail.sql),tables:tableSet(detail.sql),shingles:sqlShingles(detail.sql),columns}}),fresh=Object.fromEntries(entries.map(e=>[e.dsId,e]));try{fs.writeFileSync(file,JSON.stringify(fresh),{mode:384})}catch{}return entries}function matchDataset(sql,index,{top=3}={}){const hash=sqlFingerprint(sql),formattedHash=(()=>{try{return sqlFingerprint(core.formatSql(sql))}catch{return hash}})(),tables=tableSet(sql),shingles=sqlShingles(sql),usable=index.filter(e=>!e.unreadable&&!TRIAL.test(e.name)),identical=usable.filter(e=>e.hash===hash||e.hash===formattedHash).map(e=>({dsId:e.dsId,name:e.name,addUser:e.addUser,columns:e.columns})),identicalIds=new Set(identical.map(e=>e.dsId)),similar=usable.filter(e=>!identicalIds.has(e.dsId)&&sameSet(e.tables,tables)).map(e=>({dsId:e.dsId,name:e.name,addUser:e.addUser,score:Math.min(.99,Math.round(similarity(shingles,e.shingles)*100)/100),columns:e.columns})).filter(e=>e.score>=SIMILAR_THRESHOLD).sort((a,b)=>b.score-a.score).slice(0,top);return{tables,identical,similar}}const list=v=>(Array.isArray(v)?v:String(v??"").split(",")).map(x=>String(x).trim()).filter(Boolean),sorted=v=>[...new Set(v)].sort();function tableColumns(spec){const cols=[];for(const c of spec?.columns||[]){c.plain&&cols.push(c.plain);for(const row of c.rows||[])for(const cell of row)cols.push(String(cell).split(":")[0])}return sorted(cols)}function cardSignatureFromSpec(c){return c.type==="chart"?{kind:String(c.kind||"").toLowerCase(),dims:sorted(list(c.dim)),meas:sorted(list(c.meas)),series:sorted(list(c.series)),cols:null}:c.type==="meter"?{kind:"meter/list",dims:null,meas:null,series:null,cols:sorted(list(c.col))}:c.type==="table"&&c.spec&&typeof c.spec=="object"?{kind:"table/agtable",dims:null,meas:null,series:null,cols:tableColumns(c.spec)}:null}function cardSignatureFromDetail(detail){const cs=typeof detail.chartSetting=="string"?(()=>{try{return JSON.parse(detail.chartSetting||"{}")}catch{return{}}})():detail.chartSetting||{},kind=`${cs.parentId||detail.chartParentType||""}/${cs.id||detail.chartType||""}`.toLowerCase(),dtos=detail.indexBlockDataSettingDtos||[],byRole=t=>sorted(dtos.filter(x=>Number(x.argumentType)===t).map(x=>x.comment||x.title).filter(Boolean));return{kind,dims:byRole(2),meas:byRole(1),series:byRole(3),cols:sorted(dtos.map(x=>x.comment||x.title).filter(Boolean))}}function sameCard(a,b){return!a||!b||a.kind!==b.kind?!1:a.cols&&!a.dims?sameSet(a.cols,b.cols):sameSet(a.dims,b.dims)&&sameSet(a.meas,b.meas)&&sameSet(a.series,b.series)}async function matchCards(dsId,specCard){const want=cardSignatureFromSpec(specCard);if(!want)return[];const refs=(await core.dsRefs(dsId)).filter(r=>!TRIAL.test(r.name)),out=[];for(const r of refs)try{const detail=await core.cardDetail(r.cardId);sameCard(want,cardSignatureFromDetail(detail))&&out.push({cardId:r.cardId,name:r.name})}catch{}return out}const REUSE_MODES=new Set(["suggest","auto","off"]);async function reviewSpec(spec,readSql){const mode=spec.reuse??"suggest";if(!REUSE_MODES.has(mode))throw new Error(`reuse 只能是 ${[...REUSE_MODES].join(" / ")}`);if(mode==="off")return{mode,datasets:{},cards:{}};const index=await datasetIndex(),datasets={};for(const d of spec.datasets||[]){if(d.id||d.reuse===!1)continue;const m=matchDataset(readSql(d.sql),index),decision=m.identical.length?mode==="auto"?`复用 ${m.identical[0].dsId}`:"可复用(spec 加 reuse: auto 或把 id 写进 datasets)":m.similar.length?"有相似数据集,请比较后决定复用、在原数据集上调整,还是新建":"新建";datasets[d.key]={identical:m.identical,similar:m.similar,decision,reuseId:mode==="auto"?m.identical[0]?.dsId??null:null}}const cards={};for(const c of spec.cards||[]){if(c.type==="existing"||c.reuse===!1)continue;const ds=spec.datasets?.find(d=>d.key===c.dataset),dsId=ds?.id||datasets[c.dataset]?.reuseId||datasets[c.dataset]?.identical?.[0]?.dsId;if(!dsId)continue;const candidates=await matchCards(dsId,c);candidates.length&&(cards[c.key]={candidates,decision:mode==="auto"&&(ds?.id||datasets[c.dataset]?.reuseId)?`复用 ${candidates[0].cardId}`:"可复用已有卡片(type: existing + id)",reuseId:mode==="auto"&&(ds?.id||datasets[c.dataset]?.reuseId)?candidates[0].cardId:null})}return{mode,datasets,cards}}export{REUSE_MODES,SIMILAR_THRESHOLD,cardSignatureFromDetail,cardSignatureFromSpec,datasetIndex,matchCards,matchDataset,normalizeSql,reviewSpec,sameCard,sameSql,similarity,sqlFingerprint,sqlShingles};