@comate/zulu 1.2.1-beta.1 → 1.3.0

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 (169) hide show
  1. package/comate-engine/assets/skills/auto-commit-comate/SKILL.md +260 -0
  2. package/comate-engine/assets/skills/auto-commit-comate/references/data_structures.md +189 -0
  3. package/comate-engine/assets/skills/auto-commit-comate/references/new_version_instruction.md +209 -0
  4. package/comate-engine/assets/skills/auto-commit-comate/references/old_version_instruction.md +208 -0
  5. package/comate-engine/assets/skills/auto-commit-comate/scripts/git_diff_cli.py +196 -0
  6. package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/git_utils.py +20 -10
  7. package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/icafe/client.py +69 -40
  8. package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/icafe/farseer.py +8 -9
  9. package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/icafe/matching.py +65 -9
  10. package/comate-engine/assets/skills/auto-commit-comate/scripts/match_card_cli.py +37 -0
  11. package/comate-engine/assets/skills/cnap-comate/SKILL.md +157 -0
  12. package/comate-engine/assets/skills/cnap-comate/references/cases.md +198 -0
  13. package/comate-engine/assets/skills/cnap-comate/references/deploy-troubleshoot.md +15 -0
  14. package/comate-engine/assets/skills/cnap-comate/references/install.md +43 -0
  15. package/comate-engine/assets/skills/cnap-comate/references/kubectl.md +55 -0
  16. package/comate-engine/assets/skills/cnap-comate/references/login.md +125 -0
  17. package/comate-engine/assets/skills/cnap-comate/references/oncall.md +24 -0
  18. package/comate-engine/assets/skills/cnap-comate/scripts/install_cnap_cli.sh +36 -0
  19. package/comate-engine/assets/skills/code-security/SKILL.md +176 -0
  20. package/comate-engine/assets/skills/code-security/references/credential_hosting.md +102 -0
  21. package/comate-engine/assets/skills/code-security/references/vul_repair_sensitive.md +219 -0
  22. package/comate-engine/assets/skills/code-security/scripts/build_repair_info.py +0 -0
  23. package/comate-engine/assets/skills/code-security/scripts/credential_hosting.py +99 -0
  24. package/comate-engine/assets/skills/code-security/scripts/credential_poll.py +350 -0
  25. package/comate-engine/assets/skills/code-security/scripts/http_client.py +173 -0
  26. package/comate-engine/assets/skills/code-security/scripts/parse_scan_result.py +301 -0
  27. package/comate-engine/assets/skills/code-security/scripts/repair_vulnerability.py +261 -0
  28. package/comate-engine/assets/skills/code-security/scripts/report_chat.py +198 -0
  29. package/comate-engine/assets/skills/code-security/scripts/scan_vulnerability.py +316 -0
  30. package/comate-engine/assets/skills/code-security-comate/SKILL.md +219 -0
  31. package/comate-engine/assets/skills/code-security-comate/references/credential_hosting.md +102 -0
  32. package/comate-engine/assets/skills/code-security-comate/references/vul_repair-go_sql_injection.md +399 -0
  33. package/comate-engine/assets/skills/code-security-comate/references/vul_repair-java_sql_injection.md +591 -0
  34. package/comate-engine/assets/skills/code-security-comate/references/vul_repair-php_sql_injection.md +318 -0
  35. package/comate-engine/assets/skills/code-security-comate/references/vul_repair-python_sql_injection.md +198 -0
  36. package/comate-engine/assets/skills/code-security-comate/references/vul_repair_sensitive.md +219 -0
  37. package/comate-engine/assets/skills/code-security-comate/scripts/credential_hosting.py +87 -0
  38. package/comate-engine/assets/skills/code-security-comate/scripts/credential_poll.py +345 -0
  39. package/comate-engine/assets/skills/code-security-comate/scripts/http_client.py +173 -0
  40. package/comate-engine/assets/skills/code-security-comate/scripts/parse_scan_result.py +392 -0
  41. package/comate-engine/assets/skills/code-security-comate/scripts/repair_vulnerability.py +245 -0
  42. package/comate-engine/assets/skills/code-security-comate/scripts/report_chat.py +145 -0
  43. package/comate-engine/assets/skills/code-security-comate/scripts/scan_vulnerability.py +444 -0
  44. package/comate-engine/assets/skills/code-security-comate/scripts/utils.py +153 -0
  45. package/comate-engine/assets/skills/comate-docs-comate/SKILL.md +148 -0
  46. package/comate-engine/assets/skills/comate-docs-comate/references/doc-map-extended.md +78 -0
  47. package/comate-engine/assets/skills/comate-docs-comate/references/models-and-billing.md +51 -0
  48. package/comate-engine/assets/skills/comate-docs-comate/references/product-overview.md +73 -0
  49. package/comate-engine/assets/skills/comate-docs-comate/references/query_content.md +83 -0
  50. package/comate-engine/assets/skills/comate-docs-comate/references/query_repo.md +57 -0
  51. package/comate-engine/assets/skills/comate-docs-comate/scripts/ku_operator.py +1575 -0
  52. package/comate-engine/assets/skills/create-image-comate/SKILL.md +278 -0
  53. package/comate-engine/assets/skills/create-skill-comate/SKILL.md +308 -217
  54. package/comate-engine/assets/skills/create-skill-comate/agents/analyzer.md +274 -0
  55. package/comate-engine/assets/skills/create-skill-comate/agents/comparator.md +202 -0
  56. package/comate-engine/assets/skills/create-skill-comate/agents/grader.md +223 -0
  57. package/comate-engine/assets/skills/create-skill-comate/assets/eval_review.html +146 -0
  58. package/comate-engine/assets/skills/create-skill-comate/eval-viewer/generate_review.py +489 -0
  59. package/comate-engine/assets/skills/create-skill-comate/eval-viewer/viewer.html +1325 -0
  60. package/comate-engine/assets/skills/create-skill-comate/references/schemas.md +430 -0
  61. package/comate-engine/assets/skills/create-skill-comate/scripts/__init__.py +0 -0
  62. package/comate-engine/assets/skills/create-skill-comate/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  63. package/comate-engine/assets/skills/create-skill-comate/scripts/__pycache__/aggregate_benchmark.cpython-311.pyc +0 -0
  64. package/comate-engine/assets/skills/create-skill-comate/scripts/aggregate_benchmark.py +412 -0
  65. package/comate-engine/assets/skills/create-skill-comate/scripts/generate_report.py +334 -0
  66. package/comate-engine/assets/skills/create-skill-comate/scripts/package_skill.py +140 -0
  67. package/comate-engine/assets/skills/create-skill-comate/scripts/utils.py +53 -0
  68. package/comate-engine/assets/skills/find-skills-comate/SKILL.md +15 -12
  69. package/comate-engine/assets/skills/find-skills-comate/scripts/fetch_skills.py +32 -3
  70. package/comate-engine/assets/skills/get-ugate-token-comate/SKILL.md +159 -0
  71. package/comate-engine/assets/skills/get-ugate-token-comate/getUgateToken.py +150 -0
  72. package/comate-engine/assets/skills/icafe-comate/SKILL.md +240 -0
  73. package/comate-engine/assets/skills/icafe-comate/references/ai-workflows.md +233 -0
  74. package/comate-engine/assets/skills/icafe-comate/references/commands.md +1147 -0
  75. package/comate-engine/assets/skills/icafe-comate/references/error-handling.md +164 -0
  76. package/comate-engine/assets/skills/icafe-comate/references/git-auto-bindcard-workflow.md +201 -0
  77. package/comate-engine/assets/skills/icafe-comate/references/git-bindcard-workflow.md +327 -0
  78. package/comate-engine/assets/skills/icafe-comate/references/iql-syntax.md +327 -0
  79. package/comate-engine/assets/skills/icafe-comate/references/platform-concepts.md +317 -0
  80. package/comate-engine/assets/skills/icafe-comate/references/smart-create-workflow.md +171 -0
  81. package/comate-engine/assets/skills/icafe-comate/references/smart-find-workflow.md +127 -0
  82. package/comate-engine/assets/skills/icafe-comate/references/smart-update-workflow.md +118 -0
  83. package/comate-engine/assets/skills/icode-comate/SKILL.md +366 -0
  84. package/comate-engine/assets/skills/icode-comate/references/api/add_reviewers.md +44 -0
  85. package/comate-engine/assets/skills/icode-comate/references/api/build_fetch_command.md +89 -0
  86. package/comate-engine/assets/skills/icode-comate/references/api/check_repo_permission.md +89 -0
  87. package/comate-engine/assets/skills/icode-comate/references/api/create_branch.md +79 -0
  88. package/comate-engine/assets/skills/icode-comate/references/api/create_draft_comment.md +109 -0
  89. package/comate-engine/assets/skills/icode-comate/references/api/get_ai_cr_result.md +190 -0
  90. package/comate-engine/assets/skills/icode-comate/references/api/get_ai_review.md +97 -0
  91. package/comate-engine/assets/skills/icode-comate/references/api/get_diff_content.md +92 -0
  92. package/comate-engine/assets/skills/icode-comate/references/api/get_diff_file.md +88 -0
  93. package/comate-engine/assets/skills/icode-comate/references/api/get_machine_check.md +73 -0
  94. package/comate-engine/assets/skills/icode-comate/references/api/get_my_reviews.md +115 -0
  95. package/comate-engine/assets/skills/icode-comate/references/api/get_person_commit.md +89 -0
  96. package/comate-engine/assets/skills/icode-comate/references/api/get_person_repo.md +63 -0
  97. package/comate-engine/assets/skills/icode-comate/references/api/get_repo_branch.md +62 -0
  98. package/comate-engine/assets/skills/icode-comate/references/api/get_repo_config.md +91 -0
  99. package/comate-engine/assets/skills/icode-comate/references/api/get_repo_members.md +118 -0
  100. package/comate-engine/assets/skills/icode-comate/references/api/get_repo_reviews.md +91 -0
  101. package/comate-engine/assets/skills/icode-comate/references/api/get_review_comments.md +87 -0
  102. package/comate-engine/assets/skills/icode-comate/references/api/get_review_info.md +81 -0
  103. package/comate-engine/assets/skills/icode-comate/references/api/get_submit_settings.md +105 -0
  104. package/comate-engine/assets/skills/icode-comate/references/api/icode-api.md +86 -0
  105. package/comate-engine/assets/skills/icode-comate/references/api/publish_comments.md +72 -0
  106. package/comate-engine/assets/skills/icode-comate/references/api/set_review_score.md +58 -0
  107. package/comate-engine/assets/skills/icode-comate/references/api/start_ai_review.md +77 -0
  108. package/comate-engine/assets/skills/icode-comate/references/api/submit_review.md +50 -0
  109. package/comate-engine/assets/skills/icode-comate/references/api/trigger_ai_cr.md +63 -0
  110. package/comate-engine/assets/skills/icode-comate/references/feature/add-reviewer.md +92 -0
  111. package/comate-engine/assets/skills/icode-comate/references/feature/fix-machine-check.md +144 -0
  112. package/comate-engine/assets/skills/icode-comate/references/feature/merge-cr.md +100 -0
  113. package/comate-engine/assets/skills/icode-comate/references/feature/ssh-setup.md +106 -0
  114. package/comate-engine/assets/skills/icode-comate/references/feature/submit-acr.md +135 -0
  115. package/comate-engine/assets/skills/icode-comate/references/feature/submit-cr.md +123 -0
  116. package/comate-engine/assets/skills/icode-comate/references/git/clone.md +67 -0
  117. package/comate-engine/assets/skills/icode-comate/references/git/icode-git.md +68 -0
  118. package/comate-engine/assets/skills/icode-comate/references/git/push.md +64 -0
  119. package/comate-engine/assets/skills/icode-comate/references/git/push_cr.md +103 -0
  120. package/comate-engine/assets/skills/icode-comate/references/install.md +144 -0
  121. package/comate-engine/assets/skills/icode-comate/references/login.md +111 -0
  122. package/comate-engine/assets/skills/icode-comate/scripts/add-reviewer.sh +154 -0
  123. package/comate-engine/assets/skills/icode-comate/scripts/common.sh +145 -0
  124. package/comate-engine/assets/skills/icode-comate/scripts/fix-machine-check.sh +131 -0
  125. package/comate-engine/assets/skills/icode-comate/scripts/merge-cr.sh +105 -0
  126. package/comate-engine/assets/skills/icode-comate/scripts/ssh-setup.sh +159 -0
  127. package/comate-engine/assets/skills/icode-comate/scripts/submit-acr.sh +236 -0
  128. package/comate-engine/assets/skills/icode-comate/scripts/submit-cr.sh +104 -0
  129. package/comate-engine/assets/skills/icode-comate/scripts/test-preflight.sh +89 -0
  130. package/comate-engine/assets/skills/ku-operator-comate/SKILL.md +121 -0
  131. package/comate-engine/assets/skills/ku-operator-comate/examples.md +190 -0
  132. package/comate-engine/assets/skills/ku-operator-comate/references/add_member.md +49 -0
  133. package/comate-engine/assets/skills/ku-operator-comate/references/change_scope.md +38 -0
  134. package/comate-engine/assets/skills/ku-operator-comate/references/copy_doc.md +50 -0
  135. package/comate-engine/assets/skills/ku-operator-comate/references/create_doc.md +61 -0
  136. package/comate-engine/assets/skills/ku-operator-comate/references/delete_doc.md +31 -0
  137. package/comate-engine/assets/skills/ku-operator-comate/references/edit_content.md +568 -0
  138. package/comate-engine/assets/skills/ku-operator-comate/references/move_doc.md +45 -0
  139. package/comate-engine/assets/skills/ku-operator-comate/references/query_comment.md +79 -0
  140. package/comate-engine/assets/skills/ku-operator-comate/references/query_content.md +83 -0
  141. package/comate-engine/assets/skills/ku-operator-comate/references/query_flowchart.md +84 -0
  142. package/comate-engine/assets/skills/ku-operator-comate/references/query_permission.md +38 -0
  143. package/comate-engine/assets/skills/ku-operator-comate/references/query_recent_view.md +67 -0
  144. package/comate-engine/assets/skills/ku-operator-comate/references/query_repo.md +57 -0
  145. package/comate-engine/assets/skills/ku-operator-comate/references/query_user_info.md +37 -0
  146. package/comate-engine/assets/skills/ku-operator-comate/references/update_member.md +41 -0
  147. package/comate-engine/assets/skills/ku-operator-comate/references/upload_attachment.md +52 -0
  148. package/comate-engine/assets/skills/ku-operator-comate/scripts/ku_operator.py +1575 -0
  149. package/comate-engine/node_modules/better-sqlite3/node_modules/.bin/prebuild-install +2 -2
  150. package/comate-engine/node_modules/tree-sitter-bash/node_modules/.bin/node-gyp-build +2 -2
  151. package/comate-engine/node_modules/tree-sitter-bash/node_modules/.bin/node-gyp-build-optional +2 -2
  152. package/comate-engine/node_modules/tree-sitter-bash/node_modules/.bin/node-gyp-build-test +2 -2
  153. package/comate-engine/package.json +2 -0
  154. package/comate-engine/server.js +263 -79
  155. package/dist/bundle/index.js +8 -8
  156. package/package.json +1 -1
  157. package/comate-engine/assets/skills/figma2code-comate/codeConnect.md +0 -37
  158. package/comate-engine/assets/skills/figma2code-comate/designToken.md +0 -3
  159. package/comate-engine/assets/skills/figma2code-comate/f2cMcp.md +0 -59
  160. package/comate-engine/assets/skills/smart-commit/SKILL.md +0 -646
  161. package/comate-engine/node_modules/@comate/plugin-host/dist/index-AZIho4HV.js +0 -1
  162. package/comate-engine/node_modules/@comate/plugin-host/dist/user-BIpzRUfb.js +0 -44
  163. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/references/issue_type_mapping.json +0 -0
  164. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/references/query_reference.md +0 -0
  165. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/compat.py +0 -0
  166. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/create_card_cli.py +0 -0
  167. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/icafe/__init__.py +0 -0
  168. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/logger.py +0 -0
  169. /package/comate-engine/assets/skills/{smart-commit → auto-commit-comate}/scripts/recognize_card_cli.py +0 -0
@@ -0,0 +1,1575 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 知识库(Ku)文档操作 CLI 工具
5
+
6
+ 支持的操作:
7
+ 1. query-content - 查询文档内容
8
+ 2. query-repo - 查询知识库目录
9
+ 3. create-doc - 新建文档/目录
10
+ 4. move-doc - 移动文档
11
+ 5. delete-doc - 删除文档
12
+ 6. query-user-info - 查询当前用户信息
13
+ 7. copy-doc - 复制文档
14
+ 8. edit-content - 编辑文档正文
15
+ 9. upload-attachment - 上传附件到文档正文
16
+ 10. query-flowchart - 查询文档流程图数据
17
+ 11. add-member - 添加文档成员
18
+ 12. update-member - 更新文档成员角色
19
+ 13. change-scope - 修改文档公开/私密范围
20
+ 14. query-permission - 查询用户文档权限
21
+ 15. query-comment - 查询文档评论
22
+ 16. query-recent-view - 查询文档浏览记录
23
+
24
+ 依赖:pip install requests
25
+
26
+ 认证方式:通过 ugate-auth skill 获取个人身份 token
27
+ 环境变量:
28
+ - COMATE_USERNAME: 当前用户的百度用户名(必填)
29
+ """
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import re
35
+ import subprocess
36
+ import sys
37
+ from pathlib import Path
38
+ from typing import Any, Dict, List, Optional, Tuple
39
+ from urllib.parse import urlparse, unquote
40
+
41
+
42
+ try:
43
+ import requests
44
+ except ImportError:
45
+ print("错误: 缺少 requests 库,请执行 pip install requests", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # 常量
51
+ # ---------------------------------------------------------------------------
52
+
53
+ DEFAULT_API_BASE = "https://apigo.baidu-int.com/wiki/so"
54
+ JSON_TO_MD_URL = "http://10.11.153.100:8001/jsonToMd"
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # API 客户端
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class KuApiClient:
62
+ """知识库 REST API 客户端"""
63
+
64
+ def __init__(self, base_url: str, ugate_token: str):
65
+ """初始化知识库 API 客户端。"""
66
+ self.base_url = base_url.rstrip("/")
67
+ self._session = requests.Session()
68
+ self._session.headers["Ugate-Token"] = ugate_token
69
+ self._session.headers["access-key"] = "mYUuWUsFKk7e/oAjCKC8CA=="
70
+ self._session.headers["Content-Type"] = "application/json"
71
+
72
+ def _post(self, path: str, payload: Dict[str, Any], timeout: int = 30) -> Dict[str, Any]:
73
+ """发送 JSON POST 请求并返回响应。"""
74
+ url = f"{self.base_url}{path}"
75
+ try:
76
+ resp = self._session.post(url, json=payload, timeout=timeout)
77
+ except requests.exceptions.Timeout:
78
+ print(f"错误: 请求超时 ({timeout}s),URL: {path}", file=sys.stderr)
79
+ print("建议: 可能是服务端处理耗时较长,请稍后重试。", file=sys.stderr)
80
+ sys.exit(1)
81
+ if resp.status_code in (401, 403):
82
+ msg = f"错误: 认证失败 (HTTP {resp.status_code})," \
83
+ "ugate token 可能已过期或无效。"
84
+ print(msg, file=sys.stderr)
85
+ print("请重新发起当前操作,系统将自动触发 ugate-auth 授权流程。",
86
+ file=sys.stderr)
87
+ sys.exit(2)
88
+ if resp.status_code == 504:
89
+ print(f"错误: 网关超时 (HTTP 504),URL: {path}", file=sys.stderr)
90
+ print("建议: 服务端处理超时,请稍后重试。如果操作涉及大量内容,"
91
+ "可尝试拆分为多次小批量操作。", file=sys.stderr)
92
+ sys.exit(1)
93
+ if resp.status_code >= 500:
94
+ print(f"错误: 服务端错误 (HTTP {resp.status_code}),URL: {path}",
95
+ file=sys.stderr)
96
+ print("建议: 服务端暂时不可用,请稍后重试。", file=sys.stderr)
97
+ sys.exit(1)
98
+ resp.raise_for_status()
99
+ return resp.json()
100
+
101
+ def _post_multipart(
102
+ self, path: str, form_data: Dict[str, Any],
103
+ files: Dict[str, Any], timeout: int = 120,
104
+ ) -> Dict[str, Any]:
105
+ """发送 multipart/form-data POST 请求并返回响应。"""
106
+ url = f"{self.base_url}{path}"
107
+ headers = dict(self._session.headers)
108
+ if 'Content-Type' in headers:
109
+ del headers['Content-Type']
110
+ try:
111
+ resp = requests.post(url, headers=headers, data=form_data, files=files, timeout=timeout)
112
+ except requests.exceptions.Timeout:
113
+ print(f"错误: 请求超时 ({timeout}s),URL: {path}", file=sys.stderr)
114
+ print("建议: 文件上传超时,请检查文件大小或稍后重试。", file=sys.stderr)
115
+ sys.exit(1)
116
+ if resp.status_code in (401, 403):
117
+ msg = f"错误: 认证失败 (HTTP {resp.status_code})," \
118
+ "ugate token 可能已过期或无效。"
119
+ print(msg, file=sys.stderr)
120
+ print("请重新发起当前操作,系统将自动触发 ugate-auth 授权流程。",
121
+ file=sys.stderr)
122
+ sys.exit(2)
123
+ if resp.status_code >= 500:
124
+ print(f"错误: 服务端错误 (HTTP {resp.status_code}),URL: {path}",
125
+ file=sys.stderr)
126
+ print("建议: 服务端暂时不可用,请稍后重试。", file=sys.stderr)
127
+ sys.exit(1)
128
+ resp.raise_for_status()
129
+ return resp.json()
130
+
131
+ # ----- 1. 查询文档内容 -----
132
+
133
+ def query_content(
134
+ self,
135
+ doc_id: Optional[str] = None,
136
+ url: Optional[str] = None,
137
+ ) -> Dict[str, Any]:
138
+ """
139
+ 查询文档内容。
140
+ :param doc_id: 文档 ID(与 url 二选一)
141
+ :param url: 文档 URL(与 doc_id 二选一)
142
+ """
143
+ if not doc_id and not url:
144
+ raise ValueError("必须提供 --doc-id 或 --url 中的至少一个")
145
+ payload: Dict[str, Any] = {}
146
+ if doc_id:
147
+ payload["docId"] = doc_id
148
+ if url:
149
+ payload["url"] = url
150
+ return self._post("/ku/openapi/queryContent", payload)
151
+
152
+ # ----- 2. 查询知识库目录 -----
153
+
154
+ REPO_PAGE_SIZE = 100 # 固定每页数量,不对外暴露
155
+ ROOT_DOC_GUID = "00000000000000" # 根目录标识
156
+
157
+ def query_repo(
158
+ self,
159
+ repo_guid: str,
160
+ parent_doc_guid: str = "00000000000000",
161
+ page_num: int = 1,
162
+ ) -> Dict[str, Any]:
163
+ """
164
+ 查询知识库目录列表。
165
+ :param repo_guid: 知识库 ID(必填)
166
+ :param parent_doc_guid: 父文档 ID(默认 00000000000000 即根目录)
167
+ :param page_num: 页码,默认 1
168
+ """
169
+ payload: Dict[str, Any] = {
170
+ "repoId": repo_guid,
171
+ "parentDocGuid": parent_doc_guid,
172
+ "pageNum": page_num,
173
+ "pageSize": self.REPO_PAGE_SIZE,
174
+ }
175
+ return self._post("/ku/openapi/queryRepo", payload)
176
+
177
+ # ----- 3. 新建文档/目录 -----
178
+
179
+ def create_doc(
180
+ self,
181
+ repository_guid: str,
182
+ title: Optional[str] = None,
183
+ content: Optional[str] = None,
184
+ parent_doc_guid: Optional[str] = None,
185
+ ) -> Dict[str, Any]:
186
+ """
187
+ 新建文档或目录。
188
+ :param repository_guid: 知识库 ID(必填)
189
+ :param title: 文档标题
190
+ :param content: 文档内容(Markdown 格式)
191
+ :param parent_doc_guid: 父文档 ID(不传则创建在根目录)
192
+ """
193
+ creator_username = os.environ.get("COMATE_USERNAME", "")
194
+ if not creator_username:
195
+ print("警告: 未设置环境变量 COMATE_USERNAME,将使用空用户名", file=sys.stderr)
196
+ payload: Dict[str, Any] = {
197
+ "repositoryGuid": repository_guid,
198
+ "creatorUsername": creator_username,
199
+ }
200
+ if title:
201
+ payload["title"] = title
202
+ if content:
203
+ payload["content"] = content
204
+ if parent_doc_guid:
205
+ payload["parentDocGuid"] = parent_doc_guid
206
+ return self._post("/ku/openapi/createDoc", payload)
207
+
208
+ # ----- 4. 移动文档 -----
209
+
210
+ def move_doc(
211
+ self,
212
+ doc_id: str,
213
+ to_repo_guid: str,
214
+ to_parent_guid: Optional[str] = None,
215
+ ) -> Dict[str, Any]:
216
+ """
217
+ 移动文档到目标位置。
218
+ :param doc_id: 文档 ID(必填)
219
+ :param to_repo_guid: 目标知识库 ID(必填)
220
+ :param to_parent_guid: 目标父目录 ID(不传则移动到根目录)
221
+ """
222
+ operator_username = os.environ.get("COMATE_USERNAME", "")
223
+ if not operator_username:
224
+ print("警告: 未设置环境变量 COMATE_USERNAME,将使用空用户名", file=sys.stderr)
225
+ payload: Dict[str, Any] = {
226
+ "docId": doc_id,
227
+ "toRepoGuid": to_repo_guid,
228
+ "operatorUsername": operator_username,
229
+ }
230
+ if to_parent_guid:
231
+ payload["toParentGuid"] = to_parent_guid
232
+ return self._post("/ku/openapi/moveDoc", payload)
233
+
234
+ # ----- 5. 删除文档 -----
235
+
236
+ def delete_doc(self, doc_id: str) -> Dict[str, Any]:
237
+ """
238
+ 删除文档。
239
+ :param doc_id: 文档 ID(必填)
240
+ """
241
+ operator_username = os.environ.get("COMATE_USERNAME", "")
242
+ if not operator_username:
243
+ print("警告: 未设置环境变量 COMATE_USERNAME,将使用空用户名", file=sys.stderr)
244
+ payload: Dict[str, Any] = {
245
+ "docId": doc_id,
246
+ "operatorUsername": operator_username,
247
+ }
248
+ return self._post("/ku/openapi/deleteDoc", payload)
249
+
250
+ # ----- 6. 查询用户信息 -----
251
+
252
+ def query_user_info(self, username: str) -> Dict[str, Any]:
253
+ """
254
+ 查询用户信息。
255
+ :param username: 用户名(必填)
256
+ """
257
+ return self._post("/ku/openapi/queryUserInfo", {"username": username})
258
+
259
+ # ----- 7. 复制文档 -----
260
+
261
+ def copy_doc(
262
+ self,
263
+ doc_id: str,
264
+ to_repo_guid: Optional[str] = None,
265
+ to_parent_guid: Optional[str] = None,
266
+ new_title: Optional[str] = None,
267
+ ) -> Dict[str, Any]:
268
+ """复制文档到目标位置。"""
269
+ operator_username = os.environ.get("COMATE_USERNAME", "")
270
+ payload: Dict[str, Any] = {
271
+ "docId": doc_id,
272
+ "operatorUsername": operator_username,
273
+ }
274
+ if to_repo_guid:
275
+ payload["toRepoGuid"] = to_repo_guid
276
+ if to_parent_guid:
277
+ payload["toParentGuid"] = to_parent_guid
278
+ if new_title:
279
+ payload["newTitle"] = new_title
280
+ return self._post("/ku/openapi/copyDoc", payload)
281
+
282
+ # ----- 8. 编辑文档正文 -----
283
+
284
+ def edit_content(
285
+ self,
286
+ doc_id: str,
287
+ operations: List[Dict[str, Any]],
288
+ publish: bool = False,
289
+ ) -> Dict[str, Any]:
290
+ """编辑文档正文内容。"""
291
+ username = os.environ.get("COMATE_USERNAME", "")
292
+ payload: Dict[str, Any] = {
293
+ "docGuid": doc_id,
294
+ "editorUsername": username,
295
+ "operations": operations,
296
+ }
297
+ if publish:
298
+ payload["publish"] = True
299
+ return self._post("/ku/openapi/editContent", payload)
300
+
301
+ # ----- 9. 上传附件 -----
302
+
303
+ def upload_attachment(self, doc_id: str, file_path: str) -> Dict[str, Any]:
304
+ """上传附件到指定文档。"""
305
+ with open(file_path, 'rb') as f:
306
+ file_content = f.read()
307
+ file_name = os.path.basename(file_path)
308
+ form_data = {"docGuid": doc_id}
309
+ files = {"file": (file_name, file_content)}
310
+ return self._post_multipart("/ku/openapi/uploadAttachment", form_data, files)
311
+
312
+ # ----- 10. 查询流程图 -----
313
+
314
+ def query_flowchart(self, doc_id: str, flowchart_id: str) -> Dict[str, Any]:
315
+ """查询文档中的流程图数据。"""
316
+ return self._post("/ku/openapi/queryFlowchart", {
317
+ "docGuid": doc_id,
318
+ "flowchartId": flowchart_id,
319
+ })
320
+
321
+ # ----- 14. 添加成员 -----
322
+
323
+ def add_member(
324
+ self,
325
+ doc_id: str,
326
+ usernames: List[str],
327
+ role: str = "DocReader",
328
+ ) -> Dict[str, Any]:
329
+ """添加文档成员。"""
330
+ return self._post("/ku/openapi/addMember", {
331
+ "docId": doc_id,
332
+ "usernames": usernames,
333
+ "roleName": role,
334
+ })
335
+
336
+ # ----- 15. 更新成员角色 -----
337
+
338
+ def update_member(
339
+ self,
340
+ doc_id: str,
341
+ username: str,
342
+ role: str,
343
+ ) -> Dict[str, Any]:
344
+ """更新文档成员角色。"""
345
+ return self._post("/ku/openapi/updateMember", {
346
+ "docId": doc_id,
347
+ "username": username,
348
+ "roleName": role,
349
+ })
350
+
351
+ # ----- 16. 修改文档范围 -----
352
+
353
+ def change_scope(self, doc_id: str, scope: int) -> Dict[str, Any]:
354
+ """修改文档公开/私密范围。"""
355
+ operator_username = os.environ.get("COMATE_USERNAME", "")
356
+ return self._post("/ku/openapi/changeScope", {
357
+ "docId": doc_id,
358
+ "scope": scope,
359
+ "operatorUsername": operator_username,
360
+ })
361
+
362
+ # ----- 17. 查询权限 -----
363
+
364
+ def query_permission(
365
+ self,
366
+ doc_id: str,
367
+ usernames: List[str],
368
+ ) -> Dict[str, Any]:
369
+ """查询用户对文档的权限。"""
370
+ return self._post("/ku/openapi/queryPermission", {
371
+ "docId": doc_id,
372
+ "usernames": usernames,
373
+ })
374
+
375
+ # ----- 18. 查询评论 -----
376
+
377
+ def query_comment(
378
+ self,
379
+ doc_id: str,
380
+ bottom: bool = True,
381
+ side: bool = False,
382
+ page: int = 1,
383
+ page_size: int = 100,
384
+ ) -> Dict[str, Any]:
385
+ """查询文档评论。"""
386
+ payload: Dict[str, Any] = {
387
+ "docId": doc_id,
388
+ "queryBottomComment": bottom,
389
+ "querySideComment": side,
390
+ "pageNum": page,
391
+ "pageSize": page_size,
392
+ }
393
+ return self._post("/ku/openapi/queryComments", payload)
394
+
395
+ # ----- 19. 查询浏览记录 -----
396
+
397
+ def query_recent_view(
398
+ self,
399
+ doc_id: str,
400
+ begin_time: Optional[int] = None,
401
+ end_time: Optional[int] = None,
402
+ page: int = 1,
403
+ page_size: int = 100,
404
+ ) -> Dict[str, Any]:
405
+ """查询文档浏览记录。"""
406
+ payload: Dict[str, Any] = {
407
+ "docId": doc_id,
408
+ "pageNum": page,
409
+ "pageSize": page_size,
410
+ }
411
+ if begin_time is not None:
412
+ payload["beginTime"] = begin_time
413
+ if end_time is not None:
414
+ payload["endTime"] = end_time
415
+ return self._post("/ku/openapi/queryRecentView", payload)
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # 内容转换工具
420
+ # ---------------------------------------------------------------------------
421
+
422
+ def convert_blocks_to_markdown(blocks: List[Dict[str, Any]]) -> str:
423
+ """调用 jsonToMd 服务将知识库文档的 block 结构转换为 Markdown 格式。"""
424
+ try:
425
+ resp = requests.post(
426
+ JSON_TO_MD_URL,
427
+ json={"data": blocks},
428
+ headers={"Content-Type": "application/json"},
429
+ timeout=60,
430
+ )
431
+ resp.raise_for_status()
432
+ result = resp.json()
433
+ if result.get("code") == 200:
434
+ return result.get("data", "")
435
+ print(f"警告: jsonToMd 服务返回异常: {result}", file=sys.stderr)
436
+ return ""
437
+ except Exception as e:
438
+ print(f"错误: 调用 jsonToMd 服务失败: {e}", file=sys.stderr)
439
+ sys.exit(1)
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # 资源处理工具(图片/附件下载与上传)
444
+ # ---------------------------------------------------------------------------
445
+
446
+ # Markdown 图片引用正则:![alt](url)
447
+ _MD_IMAGE_PATTERN = re.compile(r'(!\[[^\]]*\])\(([^)]+)\)')
448
+
449
+ # Markdown 链接引用正则:[text](url)
450
+ _MD_LINK_PATTERN = re.compile(r'(?<!!)(\[[^\]]*\])\(([^)]+)\)')
451
+
452
+
453
+ def _format_file_size(size_bytes: int) -> str:
454
+ """格式化文件大小为人类可读格式。"""
455
+ if size_bytes < 1024:
456
+ return f"{size_bytes}B"
457
+ elif size_bytes < 1024 * 1024:
458
+ return f"{size_bytes / 1024:.1f}KB"
459
+ else:
460
+ return f"{size_bytes / (1024 * 1024):.1f}MB"
461
+
462
+
463
+ # MIME 类型 → 文件扩展名映射
464
+ _MIME_TO_EXT: Dict[str, str] = {
465
+ "image/png": ".png",
466
+ "image/jpeg": ".jpg",
467
+ "image/jpg": ".jpg",
468
+ "image/gif": ".gif",
469
+ "image/webp": ".webp",
470
+ "image/svg+xml": ".svg",
471
+ "image/bmp": ".bmp",
472
+ "image/tiff": ".tiff",
473
+ "image/x-icon": ".ico",
474
+ }
475
+
476
+
477
+ def _ext_from_mime(mime_type: str) -> str:
478
+ """根据 MIME 类型返回文件扩展名,未知类型返回 '.bin'。"""
479
+ if not mime_type:
480
+ return ".bin"
481
+ return _MIME_TO_EXT.get(mime_type.lower().split(";")[0].strip(), ".bin")
482
+
483
+
484
+ def download_assets_from_blocks(
485
+ markdown: str,
486
+ blocks: List[Dict[str, Any]],
487
+ doc_id: str,
488
+ save_dir: str = "./ku_assets",
489
+ ) -> Tuple[str, List[Dict[str, Any]]]:
490
+ """
491
+ 从原始 content blocks 中提取图片和附件信息,下载图片到本地,
492
+ 并在 markdown 中替换图片 URL 为本地路径、替换附件占位符为详细元信息。
493
+
494
+ jsonToMd 服务将 type=image 转为 ![](url)、type=attachment 转为 [附件] 占位符,
495
+ 会丢失附件的文件名/大小/类型等关键信息。本函数直接从原始 blocks 提取完整元数据。
496
+
497
+ :param markdown: jsonToMd 转换后的 markdown 文本
498
+ :param blocks: queryContent API 返回的原始 content block 列表
499
+ :param doc_id: 文档 ID,用于创建子目录
500
+ :param save_dir: 资源保存根目录
501
+ :return: (修改后的 markdown, 下载/识别的资源清单列表)
502
+ """
503
+ # 从 blocks 中提取图片和附件
504
+ images: List[Dict[str, Any]] = []
505
+ attachments: List[Dict[str, Any]] = []
506
+ for block in blocks:
507
+ btype = block.get("type", "")
508
+ if btype == "image":
509
+ src = block.get("src", "")
510
+ if src:
511
+ images.append({
512
+ "url": src,
513
+ "mimeType": block.get("mimeType", ""),
514
+ })
515
+ elif btype == "attachment":
516
+ file_info = block.get("fileInfo") or {}
517
+ attachments.append({
518
+ "fileId": block.get("fileId", ""),
519
+ "docId": block.get("docId", doc_id),
520
+ "name": file_info.get("name", "未知文件"),
521
+ "extension": file_info.get("extension", ""),
522
+ "size": file_info.get("size", 0),
523
+ "mimeType": file_info.get("type", ""),
524
+ })
525
+
526
+ if not images and not attachments:
527
+ return markdown, []
528
+
529
+ # 下载图片
530
+ downloaded: List[Dict[str, Any]] = []
531
+ failed: List[Dict[str, str]] = []
532
+
533
+ if images:
534
+ asset_dir = os.path.join(save_dir, doc_id)
535
+ os.makedirs(asset_dir, exist_ok=True)
536
+
537
+ for i, img in enumerate(images):
538
+ ext = _ext_from_mime(img["mimeType"])
539
+ filename = f"image_{i + 1:03d}{ext}"
540
+ local_path = os.path.join(asset_dir, filename)
541
+ try:
542
+ resp = requests.get(img["url"], timeout=30)
543
+ resp.raise_for_status()
544
+ # 如果 block 没有 mimeType,从 Content-Type 推断
545
+ if ext == ".bin":
546
+ ct = resp.headers.get("Content-Type", "")
547
+ header_ext = _ext_from_mime(ct)
548
+ if header_ext != ".bin":
549
+ ext = header_ext
550
+ filename = f"image_{i + 1:03d}{ext}"
551
+ local_path = os.path.join(asset_dir, filename)
552
+ with open(local_path, 'wb') as f:
553
+ f.write(resp.content)
554
+ downloaded.append({
555
+ 'type': 'image',
556
+ 'url': img['url'],
557
+ 'local_path': local_path,
558
+ 'filename': filename,
559
+ 'size': len(resp.content),
560
+ 'mimeType': img['mimeType'] or ct if ext != ".bin" else "",
561
+ })
562
+ except Exception as e:
563
+ failed.append({'type': 'image', 'url': img['url'], 'error': str(e)})
564
+
565
+ # 替换 markdown 中的图片 URL 为本地路径
566
+ modified_markdown = markdown
567
+ for item in downloaded:
568
+ modified_markdown = modified_markdown.replace(item['url'], item['local_path'])
569
+
570
+ # 替换 [附件] 占位符为详细元信息
571
+ attach_idx = 0
572
+
573
+ def _replace_attachment_placeholder(match: re.Match) -> str:
574
+ nonlocal attach_idx
575
+ if attach_idx >= len(attachments):
576
+ return match.group(0)
577
+ att = attachments[attach_idx]
578
+ attach_idx += 1
579
+ size_str = _format_file_size(att['size'])
580
+ return (f"[附件: {att['name']} | {size_str} | "
581
+ f"{att['mimeType']} | fileId={att['fileId']}]")
582
+
583
+ modified_markdown = re.sub(
584
+ r'\[附件\]', _replace_attachment_placeholder, modified_markdown,
585
+ )
586
+
587
+ # 在 stderr 输出资源摘要
588
+ summary_lines: List[str] = []
589
+ if images:
590
+ ok = len(downloaded)
591
+ fail = len(failed)
592
+ summary_lines.append(f"图片: 发现 {len(images)} 个,成功下载 {ok} 个")
593
+ for item in downloaded:
594
+ size_str = _format_file_size(item['size'])
595
+ summary_lines.append(
596
+ f" ✓ {item['local_path']} ({size_str}, {item['mimeType']})")
597
+ for item in failed:
598
+ summary_lines.append(f" ✗ {item['url']} (原因: {item['error']})")
599
+ if attachments:
600
+ summary_lines.append(f"附件: 发现 {len(attachments)} 个")
601
+ for att in attachments:
602
+ size_str = _format_file_size(att['size'])
603
+ summary_lines.append(
604
+ f" - {att['name']} ({size_str}, {att['mimeType']})")
605
+
606
+ if summary_lines:
607
+ print("\n[资源摘要]", file=sys.stderr)
608
+ for line in summary_lines:
609
+ print(f" {line}", file=sys.stderr)
610
+
611
+ return modified_markdown, downloaded
612
+
613
+
614
+ def upload_local_assets_in_markdown(
615
+ markdown: str,
616
+ base_dir: str,
617
+ ) -> Tuple[str, List[Dict[str, str]]]:
618
+ """
619
+ 解析 markdown 中的本地图片/附件引用,通过 dodo_cli bos upload 上传到 BOS,
620
+ 并替换为远程 URL。
621
+
622
+ :param markdown: 原始 markdown 文本
623
+ :param base_dir: 本地文件的基准目录(content-file 所在目录)
624
+ :return: (修改后的 markdown, 上传清单列表)
625
+ """
626
+ # original_ref -> resolved_absolute_path
627
+ local_refs: Dict[str, str] = {}
628
+
629
+ def _collect_local_ref(ref: str) -> Optional[str]:
630
+ """判断引用是否为本地文件,返回绝对路径。"""
631
+ ref = ref.strip()
632
+ # 跳过远程 URL、锚点、数据 URI
633
+ if ref.startswith(('http://', 'https://', '#', 'data:')):
634
+ return None
635
+ if ref in local_refs:
636
+ return local_refs[ref]
637
+ # 解析为绝对路径
638
+ if os.path.isabs(ref):
639
+ abs_path = ref
640
+ else:
641
+ abs_path = os.path.normpath(os.path.join(base_dir, ref))
642
+ # 检查文件是否存在
643
+ if not os.path.isfile(abs_path):
644
+ return None
645
+ local_refs[ref] = abs_path
646
+ return abs_path
647
+
648
+ # 扫描图片引用
649
+ for m in _MD_IMAGE_PATTERN.finditer(markdown):
650
+ ref = m.group(2).strip()
651
+ _collect_local_ref(ref)
652
+
653
+ # 扫描链接引用(只要本地文件存在就上传,不限制扩展名)
654
+ for m in _MD_LINK_PATTERN.finditer(markdown):
655
+ ref = m.group(2).strip()
656
+ if ref.startswith(('http://', 'https://', '#', 'data:')):
657
+ continue
658
+ _collect_local_ref(ref)
659
+
660
+ if not local_refs:
661
+ return markdown, []
662
+
663
+ # 上传资源
664
+ uploaded: List[Dict[str, str]] = []
665
+ failed: List[Dict[str, str]] = []
666
+
667
+ for original_ref, abs_path in local_refs.items():
668
+ try:
669
+ result = subprocess.run(
670
+ ["dodo_cli", "bos", "upload", abs_path],
671
+ capture_output=True,
672
+ text=True,
673
+ timeout=60,
674
+ )
675
+ if result.returncode != 0:
676
+ raise RuntimeError(
677
+ f"dodo_cli bos upload 返回非零退出码 {result.returncode}: {result.stderr.strip()}"
678
+ )
679
+ # 从 dodo_cli 多行输出中解析 URL,格式如:
680
+ # Uploaded: local.txt -> uploads/...
681
+ # Bucket: dodo-dev
682
+ # Size: 1.2KB
683
+ # URL: https://...
684
+ bos_url = ""
685
+ for line in result.stdout.splitlines():
686
+ line = line.strip()
687
+ if line.startswith("URL:"):
688
+ bos_url = line[len("URL:"):].strip()
689
+ break
690
+ if not bos_url:
691
+ raise RuntimeError(
692
+ f"dodo_cli bos upload 输出中未找到 URL 行,完整输出:\n{result.stdout.strip()}"
693
+ )
694
+ uploaded.append({
695
+ 'original_ref': original_ref,
696
+ 'abs_path': abs_path,
697
+ 'bos_url': bos_url,
698
+ })
699
+ except Exception as e:
700
+ failed.append({'original_ref': original_ref, 'error': str(e)})
701
+
702
+ # 替换 markdown 中的本地路径为 BOS URL
703
+ modified_markdown = markdown
704
+ for item in uploaded:
705
+ modified_markdown = modified_markdown.replace(item['original_ref'], item['bos_url'])
706
+
707
+ # 在 stderr 输出上传摘要
708
+ total_found = len(local_refs)
709
+ total_ok = len(uploaded)
710
+ total_fail = len(failed)
711
+
712
+ print(f"\n[资源上传] 共发现 {total_found} 个本地资源,成功上传 {total_ok} 个:", file=sys.stderr)
713
+ for item in uploaded:
714
+ print(f" - {item['original_ref']} -> {item['bos_url']}", file=sys.stderr)
715
+ if failed:
716
+ print(f" 上传失败 {total_fail} 个:", file=sys.stderr)
717
+ for item in failed:
718
+ print(f" - {item['original_ref']} (原因: {item['error']})", file=sys.stderr)
719
+
720
+ return modified_markdown, uploaded
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # 目录格式化工具
725
+ # ---------------------------------------------------------------------------
726
+
727
+ def format_recent_views(
728
+ doc_id: str,
729
+ views: List[Dict[str, Any]],
730
+ total: int,
731
+ total_viewers: int,
732
+ page_num: int,
733
+ page_size: int,
734
+ ) -> str:
735
+ """将浏览记录格式化为 agent 友好的文本输出。"""
736
+ from datetime import datetime, timezone, timedelta
737
+
738
+ tz = timezone(timedelta(hours=8))
739
+
740
+ lines: List[str] = []
741
+ lines.append(f"文档 {doc_id} 浏览记录(共 {total} 次,{total_viewers} 位访客)")
742
+
743
+ if not views:
744
+ lines.append("")
745
+ lines.append("(暂无浏览记录)")
746
+ return "\n".join(lines)
747
+
748
+ lines.append("")
749
+
750
+ for v in views:
751
+ nickname = v.get("nickname", "")
752
+ username = v.get("username", "")
753
+ view_time = v.get("viewTime", 0)
754
+ time_str = datetime.fromtimestamp(view_time / 1000, tz=tz).strftime("%Y-%m-%d %H:%M")
755
+ lines.append(f"- {nickname} ({username}) · {time_str}")
756
+
757
+ # 分页提示
758
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 1
759
+ if page_num < total_pages:
760
+ lines.append("")
761
+ lines.append(f"提示:还有更多记录,使用 --page {page_num + 1} 查看下一页。")
762
+
763
+ return "\n".join(lines)
764
+
765
+
766
+ def format_comments(
767
+ doc_id: str,
768
+ bottom_comments: List[Dict[str, Any]],
769
+ side_comments: List[Dict[str, Any]],
770
+ bottom_total: int,
771
+ side_total: int,
772
+ page_num: int,
773
+ page_size: int,
774
+ ) -> str:
775
+ """将评论查询结果格式化为 agent 友好的文本输出。"""
776
+ from datetime import datetime, timezone, timedelta
777
+
778
+ tz = timezone(timedelta(hours=8))
779
+
780
+ def _fmt_time(ts_ms: int) -> str:
781
+ return datetime.fromtimestamp(ts_ms / 1000, tz=tz).strftime("%Y-%m-%d %H:%M")
782
+
783
+ def _fmt_comment(comment: Dict[str, Any], index: int, indent: str = "") -> List[str]:
784
+ user = comment.get("commentUserInfo") or {}
785
+ nickname = user.get("nickname", "")
786
+ username = user.get("username", "")
787
+ text = comment.get("text", "").strip()
788
+ created = comment.get("created", 0)
789
+ quote = comment.get("quote")
790
+
791
+ lines: List[str] = []
792
+ header = f"{indent}{index}. {nickname} ({username}) · {_fmt_time(created)}"
793
+ lines.append(header)
794
+ if quote:
795
+ lines.append(f'{indent} 引用: "{quote}"')
796
+ if text:
797
+ for tl in text.split("\n"):
798
+ lines.append(f"{indent} {tl}")
799
+
800
+ children = comment.get("childrenComments") or []
801
+ for ci, child in enumerate(children, 1):
802
+ c_user = child.get("commentUserInfo") or {}
803
+ c_nick = c_user.get("nickname", "")
804
+ c_uname = c_user.get("username", "")
805
+ c_text = child.get("text", "").strip()
806
+ c_time = child.get("created", 0)
807
+ is_last = ci == len(children)
808
+ prefix = "└─" if is_last else "├─"
809
+ cont = " " if is_last else "│ "
810
+ lines.append(f"{indent} {prefix} {c_nick} ({c_uname}) · {_fmt_time(c_time)}")
811
+ if c_text:
812
+ for tl in c_text.split("\n"):
813
+ lines.append(f"{indent} {cont} {tl}")
814
+ return lines
815
+
816
+ lines: List[str] = []
817
+
818
+ # 标题
819
+ parts: List[str] = []
820
+ if bottom_total >= 0:
821
+ parts.append(f"底部 {bottom_total} 条")
822
+ if side_total >= 0:
823
+ parts.append(f"划线 {side_total} 条")
824
+ total_str = ",".join(parts)
825
+ lines.append(f"文档 {doc_id} 评论({total_str})")
826
+
827
+ if bottom_total == 0 and side_total == 0:
828
+ lines.append("")
829
+ lines.append("(暂无评论)")
830
+ return "\n".join(lines)
831
+
832
+ # 底部评论
833
+ if bottom_comments:
834
+ lines.append("")
835
+ lines.append(f"## 底部评论({bottom_total} 条)")
836
+ lines.append("")
837
+ for i, c in enumerate(bottom_comments, 1):
838
+ lines.extend(_fmt_comment(c, i))
839
+ lines.append("")
840
+
841
+ # 划线评论
842
+ if side_comments:
843
+ lines.append("")
844
+ lines.append(f"## 划线评论({side_total} 条)")
845
+ lines.append("")
846
+ for i, c in enumerate(side_comments, 1):
847
+ lines.extend(_fmt_comment(c, i))
848
+ lines.append("")
849
+
850
+ # 分页提示
851
+ total = max(bottom_total, side_total)
852
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 1
853
+ if page_num < total_pages:
854
+ lines.append(f"提示:还有更多评论,使用 --page {page_num + 1} 查看下一页。")
855
+
856
+ return "\n".join(lines)
857
+
858
+
859
+ def format_repo_tree(
860
+ repo_guid: str,
861
+ data: List[Dict[str, Any]],
862
+ page_num: int,
863
+ total: int,
864
+ parent_doc_guid: str,
865
+ ) -> str:
866
+ """将知识库目录查询结果格式化为文本树形输出。"""
867
+ lines: List[str] = []
868
+
869
+ # ---- 标题行 ----
870
+ is_root = (parent_doc_guid == KuApiClient.ROOT_DOC_GUID)
871
+ location = "根目录" if is_root else f"目录 {parent_doc_guid}"
872
+ page_size = KuApiClient.REPO_PAGE_SIZE
873
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 1
874
+
875
+ if total <= page_size:
876
+ lines.append(f"知识库 {repo_guid} {location}(共 {total} 项):")
877
+ else:
878
+ lines.append(
879
+ f"知识库 {repo_guid} {location}"
880
+ f"(第 {page_num} 页,当前 {len(data)} 项"
881
+ f" / 共 {total} 项):"
882
+ )
883
+ lines.append("")
884
+
885
+ # ---- 文档列表 ----
886
+ if not data:
887
+ lines.append("(空目录)")
888
+ else:
889
+ for doc in data:
890
+ child_count = doc.get("childCount", 0)
891
+ icon = "📁" if child_count > 0 else "📄"
892
+ name = doc.get("name", "未命名")
893
+ doc_guid = doc.get("docGuid", "")
894
+ line = f"{icon} {name} ({doc_guid})"
895
+ if child_count > 0:
896
+ line += f" [{child_count} 子文档]"
897
+ lines.append(line)
898
+
899
+ # ---- 翻页提示 ----
900
+ if total > page_size and page_num < total_pages:
901
+ lines.append("")
902
+ lines.append(f"提示:还有更多内容,使用 --page {page_num + 1} 查看下一页。")
903
+
904
+ return "\n".join(lines)
905
+
906
+
907
+ # ---------------------------------------------------------------------------
908
+ # CLI
909
+ # ---------------------------------------------------------------------------
910
+
911
+ def _get_ugate_token(username: str) -> str:
912
+ """调用 ugate-auth skill 获取 ugate token。"""
913
+ script_path = Path(__file__).parent.parent.parent / "ugate-auth" / "scripts" / "getUgateToken.py"
914
+ if not script_path.exists():
915
+ print(f"错误: 未找到 ugate-auth 脚本: {script_path}", file=sys.stderr)
916
+ sys.exit(1)
917
+ result = subprocess.run(
918
+ [sys.executable, str(script_path), username],
919
+ capture_output=True, text=True, timeout=30,
920
+ )
921
+ if result.returncode == 0:
922
+ return result.stdout.strip()
923
+ if result.returncode == 2:
924
+ # 需要用户授权
925
+ print(result.stderr, file=sys.stderr, end="")
926
+ sys.exit(2)
927
+ # 其他错误
928
+ print(result.stderr, file=sys.stderr, end="")
929
+ sys.exit(1)
930
+
931
+
932
+ def _get_client() -> KuApiClient:
933
+ """通过 ugate-auth 获取 token 并构造 API 客户端。"""
934
+ username = os.environ.get("COMATE_USERNAME", "")
935
+ if not username:
936
+ print("错误: 未设置环境变量 COMATE_USERNAME", file=sys.stderr)
937
+ sys.exit(1)
938
+ token = _get_ugate_token(username)
939
+ return KuApiClient(DEFAULT_API_BASE, token)
940
+
941
+
942
+ def _output_json(data: Any) -> None:
943
+ """将结果以格式化 JSON 输出到 stdout。"""
944
+ print(json.dumps(data, indent=2, ensure_ascii=False))
945
+
946
+
947
+ def _check_result(response: Dict[str, Any], allow_empty: bool = False) -> Dict[str, Any]:
948
+ """从 API 响应中提取 result 字段,处理 null / 错误情况。
949
+
950
+ API 响应格式: {"returnCode": 200, "result": {...}, "success": true}
951
+ 当 API 返回错误时 result 可能为 null,此时 .get("result", {}) 返回 None 而非 {}。
952
+
953
+ :param response: API 原始响应字典
954
+ :param allow_empty: 若为 True,result 为 None 时返回空字典而不退出
955
+ :return: result 字典(保证非 None)
956
+ """
957
+ inner = response.get("result")
958
+ if inner is None:
959
+ if allow_empty:
960
+ return {}
961
+ msg = (response.get("returnMessage")
962
+ or response.get("msg")
963
+ or f"API 返回错误 (returnCode={response.get('returnCode')})")
964
+ print(f"错误: {msg}", file=sys.stderr)
965
+ sys.exit(1)
966
+ return inner
967
+
968
+
969
+ def cmd_query_content(args: argparse.Namespace) -> None:
970
+ """
971
+ 查询知识库文档内容
972
+ """
973
+ client = _get_client()
974
+ result = client.query_content(doc_id=args.doc_id, url=args.url)
975
+ inner = _check_result(result)
976
+ content_blocks = inner.get("content", [])
977
+
978
+ if args.raw_json:
979
+ _output_json(content_blocks)
980
+ return
981
+
982
+ markdown = convert_blocks_to_markdown(content_blocks)
983
+
984
+ # 资源下载处理
985
+ if not args.no_download_assets:
986
+ # 确定文档 ID(优先用 doc_id,否则从 URL 提取最后一段)
987
+ doc_id = args.doc_id
988
+ if not doc_id and args.url:
989
+ doc_id = args.url.rstrip('/').split('/')[-1]
990
+ if not doc_id:
991
+ doc_id = "unknown"
992
+ markdown, _ = download_assets_from_blocks(
993
+ markdown, content_blocks, doc_id,
994
+ save_dir=args.save_assets_dir,
995
+ )
996
+
997
+ print(markdown)
998
+
999
+
1000
+ def cmd_query_repo(args: argparse.Namespace) -> None:
1001
+ """
1002
+ 查询 repo 目录结构
1003
+ """
1004
+ client = _get_client()
1005
+ parent_doc_guid = args.parent_doc_guid or KuApiClient.ROOT_DOC_GUID
1006
+ page_num = args.page or 1
1007
+ result = client.query_repo(
1008
+ repo_guid=args.repo_guid,
1009
+ parent_doc_guid=parent_doc_guid,
1010
+ page_num=page_num,
1011
+ )
1012
+ # 从响应中提取数据
1013
+ result_body = _check_result(result, allow_empty=True)
1014
+ if not result_body:
1015
+ print("数据不存在")
1016
+ else:
1017
+ data = result_body.get("data", [])
1018
+ total = result_body.get("total", 0)
1019
+ output = format_repo_tree(
1020
+ repo_guid=args.repo_guid,
1021
+ data=data,
1022
+ page_num=page_num,
1023
+ total=total,
1024
+ parent_doc_guid=parent_doc_guid,
1025
+ )
1026
+ print(output)
1027
+
1028
+
1029
+ def cmd_create_doc(args: argparse.Namespace) -> None:
1030
+ """
1031
+ 创建一个文档
1032
+ """
1033
+ client = _get_client()
1034
+ # 从文件读取内容
1035
+ content = None
1036
+ if args.content_file:
1037
+ with open(args.content_file, "r", encoding="utf-8") as f:
1038
+ content = f.read()
1039
+ # 自动上传本地图片/附件到 BOS,替换为远程 URL
1040
+ base_dir = os.path.dirname(os.path.abspath(args.content_file))
1041
+ content, _ = upload_local_assets_in_markdown(content, base_dir)
1042
+ result = client.create_doc(
1043
+ repository_guid=args.repo_guid,
1044
+ title=args.title,
1045
+ content=content,
1046
+ parent_doc_guid=args.parent_doc_guid,
1047
+ )
1048
+ _output_json(result)
1049
+
1050
+
1051
+ def cmd_move_doc(args: argparse.Namespace) -> None:
1052
+ """
1053
+ 移动文档的位置
1054
+ """
1055
+ client = _get_client()
1056
+ result = client.move_doc(
1057
+ doc_id=args.doc_id,
1058
+ to_repo_guid=args.to_repo_guid,
1059
+ to_parent_guid=args.to_parent_guid,
1060
+ )
1061
+ _output_json(result)
1062
+
1063
+
1064
+ def cmd_delete_doc(args: argparse.Namespace) -> None:
1065
+ """
1066
+ 删除文档
1067
+ """
1068
+ client = _get_client()
1069
+ result = client.delete_doc(doc_id=args.doc_id)
1070
+ _output_json(result)
1071
+
1072
+
1073
+ def cmd_query_user_info(args: argparse.Namespace) -> None:
1074
+ """
1075
+ 查询当前用户信息
1076
+ """
1077
+ username = os.environ.get("COMATE_USERNAME", "")
1078
+ if not username:
1079
+ print("错误: 未设置环境变量 COMATE_USERNAME", file=sys.stderr)
1080
+ sys.exit(1)
1081
+ client = _get_client()
1082
+ result = client.query_user_info(username=username)
1083
+ inner = _check_result(result)
1084
+ repo = inner.get("userPersonalRepo") or {}
1085
+ output = {
1086
+ "username": inner.get("username", ""),
1087
+ "nickname": inner.get("nickname", ""),
1088
+ "email": inner.get("email", ""),
1089
+ "personalRepoGuid": repo.get("repositoryGuid", ""),
1090
+ "personalRepoUrl": repo.get("url", ""),
1091
+ }
1092
+ _output_json(output)
1093
+
1094
+
1095
+ def cmd_copy_doc(args: argparse.Namespace) -> None:
1096
+ """复制文档。"""
1097
+ client = _get_client()
1098
+ result = client.copy_doc(
1099
+ doc_id=args.doc_id,
1100
+ to_repo_guid=args.to_repo_guid,
1101
+ to_parent_guid=args.to_parent_guid,
1102
+ new_title=args.new_title,
1103
+ )
1104
+ _output_json(result)
1105
+
1106
+
1107
+ def cmd_edit_content(args: argparse.Namespace) -> None:
1108
+ """编辑文档正文。"""
1109
+ with open(args.operations_file, "r", encoding="utf-8") as f:
1110
+ operations = json.load(f)
1111
+ client = _get_client()
1112
+ result = client.edit_content(
1113
+ doc_id=args.doc_id,
1114
+ operations=operations,
1115
+ publish=args.publish,
1116
+ )
1117
+ _output_json(result)
1118
+
1119
+
1120
+ def cmd_upload_attachment(args: argparse.Namespace) -> None:
1121
+ """上传附件到文档正文。"""
1122
+ if not os.path.isfile(args.file):
1123
+ print(f"错误: 文件不存在: {args.file}", file=sys.stderr)
1124
+ sys.exit(1)
1125
+ client = _get_client()
1126
+ result = client.upload_attachment(doc_id=args.doc_id, file_path=args.file)
1127
+
1128
+ # 默认自动插入正文
1129
+ if not args.no_insert:
1130
+ attach_result = _check_result(result, allow_empty=True)
1131
+ attach_id = attach_result.get("attachId", "")
1132
+ attach_name = attach_result.get("name", "")
1133
+ attach_ext = attach_result.get("extension", "")
1134
+ attach_size = attach_result.get("size", 0)
1135
+ if attach_id:
1136
+ import mimetypes
1137
+ mime_type = mimetypes.guess_type(args.file)[0] or "application/octet-stream"
1138
+ view_type = args.view_type or "attachment"
1139
+ operations = [{
1140
+ "mode": "sibling",
1141
+ "json": [{
1142
+ "type": "attachment",
1143
+ "children": [{"text": ""}],
1144
+ "fileId": attach_id,
1145
+ "docId": args.doc_id,
1146
+ "fileInfo": {
1147
+ "name": attach_name,
1148
+ "extension": attach_ext,
1149
+ "size": attach_size,
1150
+ "type": mime_type,
1151
+ },
1152
+ "url": "",
1153
+ "invalid": False,
1154
+ "viewType": view_type,
1155
+ }],
1156
+ }]
1157
+ client.edit_content(doc_id=args.doc_id, operations=operations)
1158
+ print(f"附件已上传并插入文档正文 (attachId={attach_id})", file=sys.stderr)
1159
+
1160
+ _output_json(result)
1161
+
1162
+
1163
+ def _discover_diagrams(client: KuApiClient, doc_id: str) -> List[Dict[str, Any]]:
1164
+ """从文档正文中发现所有流程图节点。
1165
+
1166
+ 调用 queryContent 获取文档 block 列表,筛选 type=diagram 的节点,
1167
+ 返回 [{id, blockId, boxSize}, ...] 列表。
1168
+ """
1169
+ result = client.query_content(doc_id=doc_id)
1170
+ inner = _check_result(result)
1171
+ blocks = inner.get("content", [])
1172
+ diagrams = []
1173
+ for idx, block in enumerate(blocks):
1174
+ if block.get("type") == "diagram":
1175
+ diagrams.append({
1176
+ "index": idx,
1177
+ "flowchartId": block.get("id", ""),
1178
+ "blockId": block.get("blockId", ""),
1179
+ "boxSize": block.get("boxSize"),
1180
+ })
1181
+ return diagrams
1182
+
1183
+
1184
+ def _parse_mxgraph_xml(xml_str: str) -> List[Dict[str, str]]:
1185
+ """从 mxGraph XML 中提取节点和边的信息,返回简要描述列表。"""
1186
+ import xml.etree.ElementTree as ET
1187
+ items: List[Dict[str, str]] = []
1188
+ try:
1189
+ root = ET.fromstring(xml_str)
1190
+ except ET.ParseError:
1191
+ return items
1192
+ for cell in root.iter("mxCell"):
1193
+ cell_id = cell.get("id", "")
1194
+ # 跳过根容器节点(id=0 或 id=1)
1195
+ if cell_id in ("0", "1"):
1196
+ continue
1197
+ value = cell.get("value", "")
1198
+ style = cell.get("style", "")
1199
+ source = cell.get("source")
1200
+ target = cell.get("target")
1201
+ if source and target:
1202
+ item = {"type": "edge", "id": cell_id,
1203
+ "source": source, "target": target}
1204
+ if value:
1205
+ item["label"] = value
1206
+ items.append(item)
1207
+ else:
1208
+ # 解析形状类型
1209
+ shape = "node"
1210
+ if "rhombus" in style:
1211
+ shape = "diamond (判断)"
1212
+ elif "process" in style:
1213
+ shape = "process (处理)"
1214
+ elif "ellipse" in style:
1215
+ shape = "ellipse (开始/结束)"
1216
+ elif "rounded" in style and "edge" not in style:
1217
+ shape = "rounded-rect"
1218
+ item = {"type": "node", "id": cell_id, "shape": shape}
1219
+ if value:
1220
+ item["label"] = value
1221
+ items.append(item)
1222
+ return items
1223
+
1224
+
1225
+ def _format_flowchart_output(
1226
+ doc_id: str,
1227
+ flowchart_id: str,
1228
+ content_xml: str,
1229
+ index: int = 0,
1230
+ ) -> str:
1231
+ """将流程图数据格式化为 agent 友好的文本输出。"""
1232
+ lines: List[str] = []
1233
+ lines.append(f"## 流程图 #{index + 1}")
1234
+ lines.append(f"- 文档 ID: {doc_id}")
1235
+ lines.append(f"- 流程图 ID: {flowchart_id}")
1236
+ lines.append(f"- 格式: mxGraph XML")
1237
+ lines.append("")
1238
+
1239
+ # 解析节点和边
1240
+ items = _parse_mxgraph_xml(content_xml)
1241
+ nodes = [it for it in items if it["type"] == "node"]
1242
+ edges = [it for it in items if it["type"] == "edge"]
1243
+
1244
+ if nodes:
1245
+ lines.append(f"### 节点 ({len(nodes)} 个)")
1246
+ for n in nodes:
1247
+ label = n.get("label", "(无文字)")
1248
+ lines.append(f" - [{n['id']}] {n.get('shape', 'node')}: {label}")
1249
+ if edges:
1250
+ lines.append(f"### 连线 ({len(edges)} 个)")
1251
+ for e in edges:
1252
+ label = f" ({e['label']})" if e.get("label") else ""
1253
+ lines.append(f" - [{e['id']}] {e['source']} → {e['target']}{label}")
1254
+
1255
+ if not nodes and not edges:
1256
+ lines.append("(流程图为空或格式无法解析)")
1257
+
1258
+ lines.append("")
1259
+ lines.append("### 原始 XML")
1260
+ lines.append("```xml")
1261
+ lines.append(content_xml)
1262
+ lines.append("```")
1263
+
1264
+ return "\n".join(lines)
1265
+
1266
+
1267
+ def cmd_query_flowchart(args: argparse.Namespace) -> None:
1268
+ """查询文档流程图数据。
1269
+
1270
+ 当 --flowchart-id 未指定时,自动从文档正文中发现所有流程图并逐一查询。
1271
+ """
1272
+ client = _get_client()
1273
+ doc_id = args.doc_id
1274
+
1275
+ # 确定要查询的流程图列表
1276
+ if args.flowchart_id:
1277
+ targets = [{"flowchartId": args.flowchart_id, "index": 0}]
1278
+ else:
1279
+ diagrams = _discover_diagrams(client, doc_id)
1280
+ if not diagrams:
1281
+ print(f"文档 {doc_id} 中未发现流程图(type=diagram 的内容块)。",
1282
+ file=sys.stderr)
1283
+ sys.exit(0)
1284
+ print(f"发现 {len(diagrams)} 个流程图,正在查询...", file=sys.stderr)
1285
+ targets = diagrams
1286
+
1287
+ # 逐一查询并输出
1288
+ outputs: List[str] = []
1289
+ for i, target in enumerate(targets):
1290
+ fid = target["flowchartId"]
1291
+ result = client.query_flowchart(doc_id=doc_id, flowchart_id=fid)
1292
+ inner = _check_result(result, allow_empty=True)
1293
+ content_xml = inner.get("content", "") if isinstance(inner, dict) else ""
1294
+
1295
+ if args.output:
1296
+ # 多个流程图时文件名加序号
1297
+ out_path = args.output
1298
+ if len(targets) > 1:
1299
+ base, ext = os.path.splitext(args.output)
1300
+ out_path = f"{base}_{i + 1}{ext}"
1301
+ with open(out_path, "w", encoding="utf-8") as f:
1302
+ f.write(content_xml)
1303
+ print(f"流程图 #{i + 1} (id={fid}) 已保存到: {out_path}")
1304
+ else:
1305
+ outputs.append(
1306
+ _format_flowchart_output(doc_id, fid, content_xml, index=i)
1307
+ )
1308
+
1309
+ if outputs:
1310
+ print("\n---\n".join(outputs))
1311
+
1312
+
1313
+ def cmd_add_member(args: argparse.Namespace) -> None:
1314
+ """添加文档成员。"""
1315
+ usernames = [u.strip() for u in args.usernames.split(",") if u.strip()]
1316
+ role = "DocMember" if args.role == "DocEditor" else args.role
1317
+ client = _get_client()
1318
+ result = client.add_member(
1319
+ doc_id=args.doc_id,
1320
+ usernames=usernames,
1321
+ role=role,
1322
+ )
1323
+ _output_json(result)
1324
+
1325
+
1326
+ def cmd_update_member(args: argparse.Namespace) -> None:
1327
+ """更新文档成员角色。"""
1328
+ role = "DocMember" if args.role == "DocEditor" else args.role
1329
+ client = _get_client()
1330
+ result = client.update_member(
1331
+ doc_id=args.doc_id,
1332
+ username=args.username,
1333
+ role=role,
1334
+ )
1335
+ _output_json(result)
1336
+
1337
+
1338
+ def cmd_change_scope(args: argparse.Namespace) -> None:
1339
+ """修改文档公开/私密范围。"""
1340
+ client = _get_client()
1341
+ result = client.change_scope(doc_id=args.doc_id, scope=args.scope)
1342
+ _output_json(result)
1343
+
1344
+
1345
+ def cmd_query_permission(args: argparse.Namespace) -> None:
1346
+ """查询用户文档权限。"""
1347
+ usernames = [u.strip() for u in args.usernames.split(",") if u.strip()]
1348
+ client = _get_client()
1349
+ result = client.query_permission(doc_id=args.doc_id, usernames=usernames)
1350
+ _output_json(result)
1351
+
1352
+
1353
+ def cmd_query_comment(args: argparse.Namespace) -> None:
1354
+ """查询文档评论。"""
1355
+ client = _get_client()
1356
+ query_type = args.type
1357
+ bottom = query_type in ("all", "bottom")
1358
+ side = query_type in ("all", "side")
1359
+ result = client.query_comment(
1360
+ doc_id=args.doc_id,
1361
+ bottom=bottom,
1362
+ side=side,
1363
+ page=args.page,
1364
+ page_size=args.page_size,
1365
+ )
1366
+
1367
+ if args.raw_json:
1368
+ _output_json(result)
1369
+ return
1370
+
1371
+ inner = _check_result(result, allow_empty=True)
1372
+ bottom_result = inner.get("bottomCommentResult") or {}
1373
+ side_result = inner.get("sideCommentResult") or {}
1374
+
1375
+ output = format_comments(
1376
+ doc_id=args.doc_id,
1377
+ bottom_comments=bottom_result.get("comments", []),
1378
+ side_comments=side_result.get("comments", []),
1379
+ bottom_total=bottom_result.get("totalCount", 0) if bottom else -1,
1380
+ side_total=side_result.get("totalCount", 0) if side else -1,
1381
+ page_num=args.page,
1382
+ page_size=args.page_size,
1383
+ )
1384
+ print(output)
1385
+
1386
+
1387
+ def cmd_query_recent_view(args: argparse.Namespace) -> None:
1388
+ """查询文档浏览记录。"""
1389
+ from datetime import datetime, timezone, timedelta
1390
+
1391
+ tz = timezone(timedelta(hours=8))
1392
+
1393
+ def _parse_time(s: Optional[str]) -> Optional[int]:
1394
+ if not s:
1395
+ return None
1396
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
1397
+ try:
1398
+ dt = datetime.strptime(s, fmt).replace(tzinfo=tz)
1399
+ return int(dt.timestamp() * 1000)
1400
+ except ValueError:
1401
+ continue
1402
+ print(f"错误: 无法解析时间 '{s}',支持格式: YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DD HH:MM:SS",
1403
+ file=sys.stderr)
1404
+ sys.exit(1)
1405
+
1406
+ client = _get_client()
1407
+ result = client.query_recent_view(
1408
+ doc_id=args.doc_id,
1409
+ begin_time=_parse_time(args.begin_time),
1410
+ end_time=_parse_time(args.end_time),
1411
+ page=args.page,
1412
+ page_size=args.page_size,
1413
+ )
1414
+
1415
+ if args.raw_json:
1416
+ _output_json(result)
1417
+ return
1418
+
1419
+ inner = _check_result(result, allow_empty=True)
1420
+ output = format_recent_views(
1421
+ doc_id=args.doc_id,
1422
+ views=inner.get("data", []),
1423
+ total=inner.get("total", 0),
1424
+ total_viewers=inner.get("totalViewers", 0),
1425
+ page_num=args.page,
1426
+ page_size=args.page_size,
1427
+ )
1428
+ print(output)
1429
+
1430
+
1431
+ def main() -> None:
1432
+ """
1433
+ 主入口函数
1434
+ """
1435
+ parser = argparse.ArgumentParser(
1436
+ description="知识库(Ku)文档操作 CLI 工具",
1437
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1438
+ )
1439
+ subparsers = parser.add_subparsers(dest="command", help="可用子命令")
1440
+
1441
+ # ---- query-content ----
1442
+ p_qc = subparsers.add_parser("query-content", help="查询文档内容(输出 Markdown 格式)")
1443
+ p_qc.add_argument("--doc-id", help="文档 ID")
1444
+ p_qc.add_argument("--url", help="文档 URL")
1445
+ p_qc.add_argument("--save-assets-dir", default="./ku_assets",
1446
+ help="资源保存目录(默认 ./ku_assets)")
1447
+ p_qc.add_argument("--no-download-assets", action="store_true",
1448
+ help="禁用自动下载图片/附件")
1449
+ p_qc.add_argument("--raw-json", action="store_true",
1450
+ help="输出原始 JSON block 结构(含 blockId),不转换为 Markdown")
1451
+ p_qc.set_defaults(func=cmd_query_content)
1452
+
1453
+ # ---- query-repo ----
1454
+ p_qr = subparsers.add_parser("query-repo", help="查询知识库目录(文本树形输出)")
1455
+ p_qr.add_argument("--repo-guid", required=True, help="知识库 ID(必填)")
1456
+ p_qr.add_argument("--parent-doc-guid", help="父文档 ID(默认查询根目录)")
1457
+ p_qr.add_argument("--page", type=int, default=1, help="页码(默认 1,每页 100 条)")
1458
+ p_qr.set_defaults(func=cmd_query_repo)
1459
+
1460
+ # ---- create-doc ----
1461
+ p_cd = subparsers.add_parser("create-doc", help="新建文档/目录")
1462
+ p_cd.add_argument("--repo-guid", required=True, help="知识库 ID(必填)")
1463
+ p_cd.add_argument("--title", help="文档标题")
1464
+ p_cd.add_argument("--content-file", help="从本地文件读取文档内容(Markdown 格式)")
1465
+ p_cd.add_argument("--parent-doc-guid", help="父文档 ID(不传则创建在根目录)")
1466
+ p_cd.set_defaults(func=cmd_create_doc)
1467
+
1468
+ # ---- move-doc ----
1469
+ p_md = subparsers.add_parser("move-doc", help="移动文档")
1470
+ p_md.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1471
+ p_md.add_argument("--to-repo-guid", required=True, help="目标知识库 ID(必填)")
1472
+ p_md.add_argument("--to-parent-guid", help="目标父目录 ID(不传则移动到根目录)")
1473
+ p_md.set_defaults(func=cmd_move_doc)
1474
+
1475
+ # ---- delete-doc ----
1476
+ p_dd = subparsers.add_parser("delete-doc", help="删除文档")
1477
+ p_dd.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1478
+ p_dd.set_defaults(func=cmd_delete_doc)
1479
+
1480
+ # ---- query-user-info ----
1481
+ p_qui = subparsers.add_parser("query-user-info", help="查询当前用户信息")
1482
+ p_qui.set_defaults(func=cmd_query_user_info)
1483
+
1484
+ # ---- copy-doc ----
1485
+ p_cp = subparsers.add_parser("copy-doc", help="复制文档")
1486
+ p_cp.add_argument("--doc-id", required=True, help="源文档 ID(必填)")
1487
+ p_cp.add_argument("--to-repo-guid", help="目标知识库 ID")
1488
+ p_cp.add_argument("--to-parent-guid", help="目标父目录 ID")
1489
+ p_cp.add_argument("--new-title", help="新文档标题")
1490
+ p_cp.set_defaults(func=cmd_copy_doc)
1491
+
1492
+ # ---- edit-content ----
1493
+ p_ec = subparsers.add_parser("edit-content", help="编辑文档正文")
1494
+ p_ec.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1495
+ p_ec.add_argument("--operations-file", required=True, help="JSON 操作文件路径(必填)")
1496
+ p_ec.add_argument("--publish", action="store_true", help="编辑后同步发布")
1497
+ p_ec.set_defaults(func=cmd_edit_content)
1498
+
1499
+ # ---- upload-attachment ----
1500
+ p_ua = subparsers.add_parser("upload-attachment", help="上传附件到文档正文")
1501
+ p_ua.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1502
+ p_ua.add_argument("--file", required=True, help="本地文件路径(必填)")
1503
+ p_ua.add_argument("--no-insert", action="store_true", help="只上传不插入正文")
1504
+ p_ua.add_argument("--view-type", choices=["attachment", "preview"], help="显示方式(默认 attachment)")
1505
+ p_ua.set_defaults(func=cmd_upload_attachment)
1506
+
1507
+ # ---- query-flowchart ----
1508
+ p_qf = subparsers.add_parser("query-flowchart", help="查询文档流程图数据")
1509
+ p_qf.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1510
+ p_qf.add_argument("--flowchart-id", help="流程图 ID(不传则自动从文档正文中发现所有流程图)")
1511
+ p_qf.add_argument("--output", help="输出文件路径(多个流程图时自动添加序号后缀)")
1512
+ p_qf.set_defaults(func=cmd_query_flowchart)
1513
+
1514
+ # ---- add-member ----
1515
+ p_am = subparsers.add_parser("add-member", help="添加文档成员")
1516
+ p_am.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1517
+ p_am.add_argument("--usernames", required=True, help="用户名,多个用逗号分隔(必填)")
1518
+ p_am.add_argument("--role", default="DocReader",
1519
+ choices=["DocReader", "DocEditor", "DocMember", "DocAdmin"],
1520
+ help="角色:DocReader=可读, "
1521
+ "DocEditor/DocMember=可编辑, "
1522
+ "DocAdmin=管理员(默认 DocReader)")
1523
+ p_am.set_defaults(func=cmd_add_member)
1524
+
1525
+ # ---- update-member ----
1526
+ p_um = subparsers.add_parser("update-member", help="更新文档成员角色")
1527
+ p_um.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1528
+ p_um.add_argument("--username", required=True, help="用户名(必填)")
1529
+ p_um.add_argument("--role", required=True,
1530
+ choices=["DocReader", "DocEditor", "DocMember", "DocAdmin"],
1531
+ help="新角色:DocReader=可读, DocEditor/DocMember=可编辑, DocAdmin=管理员")
1532
+ p_um.set_defaults(func=cmd_update_member)
1533
+
1534
+ # ---- change-scope ----
1535
+ p_cs = subparsers.add_parser("change-scope", help="修改文档公开/私密范围")
1536
+ p_cs.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1537
+ p_cs.add_argument("--scope", required=True, type=int, choices=[5, 6, 20],
1538
+ help="范围:5=公开可读, 6=公开可编辑, 20=私密")
1539
+ p_cs.set_defaults(func=cmd_change_scope)
1540
+
1541
+ # ---- query-permission ----
1542
+ p_qp = subparsers.add_parser("query-permission", help="查询用户文档权限")
1543
+ p_qp.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1544
+ p_qp.add_argument("--usernames", required=True, help="用户名,多个用逗号分隔(必填)")
1545
+ p_qp.set_defaults(func=cmd_query_permission)
1546
+
1547
+ # ---- query-comment ----
1548
+ p_qcm = subparsers.add_parser("query-comment", help="查询文档评论")
1549
+ p_qcm.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1550
+ p_qcm.add_argument("--type", default="all", choices=["all", "bottom", "side"],
1551
+ help="评论类型:all=全部(默认), bottom=底部评论, side=划线评论")
1552
+ p_qcm.add_argument("--page", type=int, default=1, help="页码(默认 1)")
1553
+ p_qcm.add_argument("--page-size", type=int, default=100, help="每页条数(默认 100)")
1554
+ p_qcm.add_argument("--raw-json", action="store_true", help="输出原始 JSON")
1555
+ p_qcm.set_defaults(func=cmd_query_comment)
1556
+
1557
+ # ---- query-recent-view ----
1558
+ p_qrv = subparsers.add_parser("query-recent-view", help="查询文档浏览记录")
1559
+ p_qrv.add_argument("--doc-id", required=True, help="文档 ID(必填)")
1560
+ p_qrv.add_argument("--begin-time", help="起始时间(如 2026-03-20、2026-03-20 17:00)")
1561
+ p_qrv.add_argument("--end-time", help="结束时间(如 2026-03-21、2026-03-21 09:00)")
1562
+ p_qrv.add_argument("--page", type=int, default=1, help="页码(默认 1)")
1563
+ p_qrv.add_argument("--page-size", type=int, default=100, help="每页条数(默认 100)")
1564
+ p_qrv.add_argument("--raw-json", action="store_true", help="输出原始 JSON")
1565
+ p_qrv.set_defaults(func=cmd_query_recent_view)
1566
+
1567
+ args = parser.parse_args()
1568
+ if not args.command:
1569
+ parser.print_help()
1570
+ sys.exit(1)
1571
+ args.func(args)
1572
+
1573
+
1574
+ if __name__ == "__main__":
1575
+ main()