dsh-cloudq 0.1.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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/assets/cloudq.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +3405 -0
- package/lib/index.js +910 -0
- package/lib/types/client/index.d.ts +4 -0
- package/lib/types/index.d.ts +13 -0
- package/package.json +119 -0
- package/skills/cloudq/SKILL.md +359 -0
- package/skills/cloudq/references/api/CloudQChatCompletions.md +178 -0
- package/skills/cloudq/scripts/check_env.py +811 -0
- package/skills/cloudq/scripts/cleanup.py +419 -0
- package/skills/cloudq/scripts/create_role.py +281 -0
- package/skills/cloudq/scripts/credential_manager.py +554 -0
- package/skills/cloudq/scripts/login.py +410 -0
- package/skills/cloudq/scripts/login_url.py +404 -0
- package/skills/cloudq/scripts/logout.py +36 -0
- package/skills/cloudq/scripts/save_ak.py +156 -0
- package/skills/cloudq/scripts/tcloud_api.py +329 -0
- package/skills/cloudq/scripts/tcloud_async_task.py +345 -0
- package/skills/cloudq/scripts/tcloud_sse_api.py +765 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
腾讯云智能顾问 CloudQ SSE 流式调用脚本
|
|
4
|
+
|
|
5
|
+
通过 TC3-HMAC-SHA256 签名调用 CloudQ 对话接口。
|
|
6
|
+
支持 AK/SK、OAuth 和 Connector 三种鉴权方式,统一使用 CloudQChatCompletions 接口。
|
|
7
|
+
|
|
8
|
+
支持同步(SSE 流式)和异步两种对话模式:
|
|
9
|
+
- 同步(Async=false,默认):SSE 流式返回,连接保持到对话结束
|
|
10
|
+
- 异步(Async=true):立即返回 accepted 帧,通过 DescribeCloudQAsyncTask 轮询结果
|
|
11
|
+
|
|
12
|
+
接口固定参数:
|
|
13
|
+
service: advisor
|
|
14
|
+
host: advisor.ai.tencentcloudapi.com
|
|
15
|
+
action: CloudQChatCompletions
|
|
16
|
+
version: 2020-07-21
|
|
17
|
+
|
|
18
|
+
请求格式:
|
|
19
|
+
{"SessionID":"<uuid>","Question":"...","Source":"<platform>","Async":false}
|
|
20
|
+
异步模式: {"SessionID":"<uuid>","Question":"...","Source":"<platform>","Async":true}
|
|
21
|
+
|
|
22
|
+
响应格式(SSE 大驼峰字段):
|
|
23
|
+
event:<chat_id>
|
|
24
|
+
data:{"SessionId":"...","ChatId":"...","Event":"content","Content":"...","IsFinal":false}
|
|
25
|
+
|
|
26
|
+
异步 accepted 帧:
|
|
27
|
+
event: accepted
|
|
28
|
+
data:{"ChatId":"<异步任务ID>","SessionId":"<会话ID>","Content":"任务已受理..."}
|
|
29
|
+
|
|
30
|
+
纯 Python 标准库实现,无外部依赖。
|
|
31
|
+
|
|
32
|
+
会话管理:
|
|
33
|
+
SessionID 必填模式:
|
|
34
|
+
- 调用方在首次调用前通过 Python 生成 UUID:python3 -c "import uuid; print(uuid.uuid4())"
|
|
35
|
+
- 每次调用必须通过 --session-id 传入
|
|
36
|
+
- 同一会话使用相同 session_id,新会话生成新 UUID
|
|
37
|
+
- 不同会话使用不同 session_id,支持多会话并行
|
|
38
|
+
|
|
39
|
+
用法 (命令行):
|
|
40
|
+
# 同步模式(默认)
|
|
41
|
+
python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid>
|
|
42
|
+
# 异步模式
|
|
43
|
+
python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid> --async
|
|
44
|
+
|
|
45
|
+
示例:
|
|
46
|
+
python3 tcloud_sse_api.py '列出架构图' --source codebuddy --session-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
47
|
+
python3 tcloud_sse_api.py '详细说说' --source codebuddy --session-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
48
|
+
|
|
49
|
+
兼容旧用法(仍支持位置参数传入 session_id,但不推荐):
|
|
50
|
+
python3 tcloud_sse_api.py '列出架构图' <session_id> [source]
|
|
51
|
+
|
|
52
|
+
作为模块导入:
|
|
53
|
+
from tcloud_sse_api import call_sse_api, generate_session_id
|
|
54
|
+
session_id = generate_session_id()
|
|
55
|
+
result = call_sse_api(
|
|
56
|
+
question="列出架构图",
|
|
57
|
+
session_id=session_id,
|
|
58
|
+
on_event=lambda e: print(e["data"].get("Content", ""), end="", flush=True),
|
|
59
|
+
)
|
|
60
|
+
# result["data"]["session_id"] 可用于后续调用
|
|
61
|
+
|
|
62
|
+
鉴权方式(三选一):
|
|
63
|
+
方式一:环境变量 AK/SK
|
|
64
|
+
TENCENTCLOUD_SECRET_ID - 腾讯云 SecretId
|
|
65
|
+
TENCENTCLOUD_SECRET_KEY - 腾讯云 SecretKey
|
|
66
|
+
TENCENTCLOUD_TOKEN - 临时密钥 Token(可选)
|
|
67
|
+
|
|
68
|
+
方式二:OAuth 浏览器授权
|
|
69
|
+
python3 scripts/login.py # 登录后凭证自动保存
|
|
70
|
+
|
|
71
|
+
方式三:CloudQ Connector 临时密钥(OneId 方案)
|
|
72
|
+
由 Agent 通过 MCP Tool CloudQConnector_get_available_tmp_secret 获取并写入 credential.json
|
|
73
|
+
|
|
74
|
+
输出格式(统一 JSON):
|
|
75
|
+
成功: {"success": true, "action": "...", "data": {...}, "requestId": "..."}
|
|
76
|
+
失败: {"success": false, "action": "...", "error": {...}, "requestId": "..."}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
import hashlib
|
|
80
|
+
import hmac
|
|
81
|
+
import json
|
|
82
|
+
import os
|
|
83
|
+
import re
|
|
84
|
+
import ssl
|
|
85
|
+
import subprocess
|
|
86
|
+
import sys
|
|
87
|
+
import time
|
|
88
|
+
import uuid
|
|
89
|
+
from datetime import datetime, timezone
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
from typing import Optional
|
|
92
|
+
import urllib.request
|
|
93
|
+
from urllib.request import Request
|
|
94
|
+
from urllib.error import URLError, HTTPError
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# 固定参数
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
SERVICE = "advisor"
|
|
100
|
+
HOST = "advisor.ai.tencentcloudapi.com"
|
|
101
|
+
ACTION = "CloudQChatCompletions" # 统一接口,AK/SK 和 OAuth 均使用
|
|
102
|
+
ACTION_CANCEL_CHAT = "CancelCloudQChat" # 取消同步 SSE 编排
|
|
103
|
+
VERSION = "2020-07-21"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# 会话管理
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_session_id() -> str:
|
|
112
|
+
"""
|
|
113
|
+
生成新的 SessionID(UUID v4)。
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
str: UUID v4 格式的 SessionID
|
|
117
|
+
"""
|
|
118
|
+
return str(uuid.uuid4())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# 内部工具函数
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _get_ssl_context():
|
|
126
|
+
"""获取 SSL 上下文,兼容各平台 CA 证书差异"""
|
|
127
|
+
try:
|
|
128
|
+
import certifi
|
|
129
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
130
|
+
except ImportError:
|
|
131
|
+
# certifi 不可用时降级到系统默认 CA(Windows/Linux 系统 CA 通常可用)
|
|
132
|
+
return ssl.create_default_context()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sign_tc3(key: bytes, msg: str) -> bytes:
|
|
136
|
+
"""TC3 HMAC-SHA256 签名辅助函数"""
|
|
137
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _make_error(action: str, code: str, message: str, request_id: str = "") -> dict:
|
|
141
|
+
"""构造统一错误结果"""
|
|
142
|
+
return {
|
|
143
|
+
"success": False,
|
|
144
|
+
"action": action,
|
|
145
|
+
"error": {"code": code, "message": message},
|
|
146
|
+
"requestId": request_id,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _make_success(action: str, data: dict, request_id: str) -> dict:
|
|
151
|
+
"""构造统一成功结果"""
|
|
152
|
+
return {
|
|
153
|
+
"success": True,
|
|
154
|
+
"action": action,
|
|
155
|
+
"data": data,
|
|
156
|
+
"requestId": request_id,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# SSE 行解析
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def parse_sse_line(line: str):
|
|
165
|
+
"""
|
|
166
|
+
解析单行 SSE 数据。
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
dict | None:
|
|
170
|
+
- id 行: {"type": "id", "value": "..."}
|
|
171
|
+
- event 行: {"event": "<value>"}
|
|
172
|
+
- data 行(JSON 有效): {"event": "data", "data": {...}}
|
|
173
|
+
- data 行(JSON 无效): {"event": "data", "raw": "..."}
|
|
174
|
+
- 空行/注释行: None
|
|
175
|
+
"""
|
|
176
|
+
if not line or line.startswith(":"):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
if line.startswith("id:"):
|
|
180
|
+
return {"type": "id", "value": line[3:].strip()}
|
|
181
|
+
|
|
182
|
+
if line.startswith("data:"):
|
|
183
|
+
payload = line[5:].lstrip()
|
|
184
|
+
try:
|
|
185
|
+
return {"event": "data", "data": json.loads(payload)}
|
|
186
|
+
except (json.JSONDecodeError, ValueError):
|
|
187
|
+
return {"event": "data", "raw": payload}
|
|
188
|
+
|
|
189
|
+
if line.startswith("event:"):
|
|
190
|
+
value = line[6:].strip()
|
|
191
|
+
return {"event": value}
|
|
192
|
+
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# SSE 流式 API 调用
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def call_sse_api(question: str, session_id: str,
|
|
201
|
+
secret_id: str = None, secret_key: str = None,
|
|
202
|
+
token: str = None, region: str = "ap-guangzhou",
|
|
203
|
+
source: str = "", on_event=None,
|
|
204
|
+
async_mode: bool = True) -> dict:
|
|
205
|
+
"""
|
|
206
|
+
调用 CloudQ SSE 流式 API(统一使用 CloudQChatCompletions)。
|
|
207
|
+
|
|
208
|
+
默认异步模式:立即返回 accepted 帧,连接关闭,
|
|
209
|
+
后续通过 DescribeCloudQAsyncTask 轮询结果。
|
|
210
|
+
设置 async_mode=False 可切回同步 SSE 流式模式(不推荐,客户端有 60s 超时风险)。
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
question: 用户问题
|
|
214
|
+
session_id: 会话 ID(同一对话必须保持不变)
|
|
215
|
+
secret_id: SecretId,不传则自动获取
|
|
216
|
+
secret_key: SecretKey,不传则自动获取
|
|
217
|
+
token: 临时密钥 Token,不传则自动获取
|
|
218
|
+
region: 地域字符串,默认 ap-guangzhou
|
|
219
|
+
source: 调用来源平台标识(不区分大小写),如 codebuddy、openclaw 等
|
|
220
|
+
on_event: 回调函数(异步模式下 accepted 帧后即返回,回调不会被调用)
|
|
221
|
+
async_mode: 是否异步模式,默认 True(推荐,避免客户端超时)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
dict: 统一格式的结果字典
|
|
225
|
+
data 含 chat_id, session_id, content, is_accepted
|
|
226
|
+
"""
|
|
227
|
+
# ---- 凭证获取 ----
|
|
228
|
+
cred_source = "env" # 默认假设环境变量
|
|
229
|
+
audit_jwt = "" # Connector 模式下的审计 JWT
|
|
230
|
+
|
|
231
|
+
if secret_id and secret_key:
|
|
232
|
+
# 显式传入凭证,使用 AK/SK 模式
|
|
233
|
+
token = token or os.environ.get("TENCENTCLOUD_TOKEN", "")
|
|
234
|
+
else:
|
|
235
|
+
# 通过 credential_manager 统一获取
|
|
236
|
+
try:
|
|
237
|
+
from credential_manager import (
|
|
238
|
+
get_credential, CredentialExpiredError, CredentialNotFoundError,
|
|
239
|
+
)
|
|
240
|
+
cred = get_credential()
|
|
241
|
+
secret_id = cred["secretId"]
|
|
242
|
+
secret_key = cred["secretKey"]
|
|
243
|
+
token = cred.get("token", "")
|
|
244
|
+
cred_source = cred.get("source", "env")
|
|
245
|
+
audit_jwt = cred.get("auditJwt", "") # Connector 模式附带
|
|
246
|
+
except ImportError:
|
|
247
|
+
# credential_manager 不可用,回退到环境变量
|
|
248
|
+
secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
|
|
249
|
+
secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")
|
|
250
|
+
token = os.environ.get("TENCENTCLOUD_TOKEN", "")
|
|
251
|
+
except CredentialExpiredError as e:
|
|
252
|
+
return _make_error(
|
|
253
|
+
ACTION, "CredentialExpired",
|
|
254
|
+
f"凭证已过期。{e}"
|
|
255
|
+
)
|
|
256
|
+
except CredentialNotFoundError:
|
|
257
|
+
return _make_error(
|
|
258
|
+
ACTION, "NeedAuth",
|
|
259
|
+
"未找到凭证,请先通过 OAuth 登录、配置 AK/SK 环境变量,"
|
|
260
|
+
"或通过 Connector 获取临时密钥。"
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
return _make_error(
|
|
264
|
+
ACTION, "NeedAuth", str(e)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if not secret_id or not secret_key:
|
|
268
|
+
return _make_error(
|
|
269
|
+
ACTION, "MissingCredentials",
|
|
270
|
+
"当前授权方式的凭证缺失,无法调用 API。请检查凭证配置是否正确。"
|
|
271
|
+
"如需更换授权方式,请明确告知。"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# 统一使用 CloudQChatCompletions 接口(AK/SK、OAuth、Connector 均支持)
|
|
275
|
+
action = ACTION
|
|
276
|
+
|
|
277
|
+
payload = {"Question": question, "SessionID": session_id}
|
|
278
|
+
if async_mode:
|
|
279
|
+
payload["Async"] = True
|
|
280
|
+
# OAuth / Connector 模式下需要标记使用 CloudQ 控制台凭证
|
|
281
|
+
if cred_source in ("oauth", "connector"):
|
|
282
|
+
payload["UseCloudQCredential"] = True
|
|
283
|
+
# Source 字段统一发送(CloudQChatCompletions 三种鉴权方式均支持)
|
|
284
|
+
if source:
|
|
285
|
+
payload["Source"] = source
|
|
286
|
+
payload_str = json.dumps(payload, separators=(",", ":"))
|
|
287
|
+
|
|
288
|
+
# ---- TC3-HMAC-SHA256 签名 ----
|
|
289
|
+
algorithm = "TC3-HMAC-SHA256"
|
|
290
|
+
timestamp = int(time.time())
|
|
291
|
+
date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
292
|
+
|
|
293
|
+
hashed_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
|
|
294
|
+
canonical_request = (
|
|
295
|
+
f"POST\n/\n\n"
|
|
296
|
+
f"content-type:application/json\n"
|
|
297
|
+
f"host:{HOST}\n"
|
|
298
|
+
f"x-tc-action:{action.lower()}\n\n"
|
|
299
|
+
f"content-type;host;x-tc-action\n"
|
|
300
|
+
f"{hashed_payload}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
credential_scope = f"{date}/{SERVICE}/tc3_request"
|
|
304
|
+
hashed_cr = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
|
305
|
+
string_to_sign = f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_cr}"
|
|
306
|
+
|
|
307
|
+
secret_date = _sign_tc3(f"TC3{secret_key}".encode("utf-8"), date)
|
|
308
|
+
secret_service = _sign_tc3(secret_date, SERVICE)
|
|
309
|
+
secret_signing = _sign_tc3(secret_service, "tc3_request")
|
|
310
|
+
signature = hmac.new(
|
|
311
|
+
secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256
|
|
312
|
+
).hexdigest()
|
|
313
|
+
|
|
314
|
+
authorization = (
|
|
315
|
+
f"{algorithm} "
|
|
316
|
+
f"Credential={secret_id}/{credential_scope}, "
|
|
317
|
+
f"SignedHeaders=content-type;host;x-tc-action, "
|
|
318
|
+
f"Signature={signature}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
headers = {
|
|
322
|
+
"Authorization": authorization,
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
"Accept": "text/event-stream",
|
|
325
|
+
"Host": HOST,
|
|
326
|
+
"X-TC-Action": action,
|
|
327
|
+
"X-TC-Timestamp": str(timestamp),
|
|
328
|
+
"X-TC-Version": VERSION,
|
|
329
|
+
"X-TC-Region": region,
|
|
330
|
+
}
|
|
331
|
+
if token:
|
|
332
|
+
headers["X-TC-Token"] = token
|
|
333
|
+
|
|
334
|
+
req = Request(
|
|
335
|
+
f"https://{HOST}", data=payload_str.encode("utf-8"),
|
|
336
|
+
headers=headers, method="POST",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# ---- 发送请求并解析 SSE 流 ----
|
|
340
|
+
try:
|
|
341
|
+
ctx = _get_ssl_context()
|
|
342
|
+
resp = urllib.request.urlopen(req, context=ctx, timeout=1200)
|
|
343
|
+
except HTTPError as e:
|
|
344
|
+
return _handle_http_error(e, action)
|
|
345
|
+
except URLError as e:
|
|
346
|
+
return _make_error(
|
|
347
|
+
action, "NetworkError",
|
|
348
|
+
f"网络连接失败,请检查网络和域名 {HOST} 是否可达: {e.reason}"
|
|
349
|
+
)
|
|
350
|
+
except Exception as e:
|
|
351
|
+
return _make_error(action, "NetworkError", f"请求异常: {e}")
|
|
352
|
+
|
|
353
|
+
# 检查响应类型:腾讯云 API 可能返回 HTTP 200 + JSON 错误体(非 SSE 流)
|
|
354
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
355
|
+
if "text/event-stream" not in content_type:
|
|
356
|
+
return _handle_non_sse_response(resp, action)
|
|
357
|
+
|
|
358
|
+
return _parse_sse_stream(resp, on_event, action, cred_source, session_id, async_mode)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parse_sse_stream(resp, on_event, action: str, cred_source: str = "env",
|
|
362
|
+
session_id: str = "", async_mode: bool = True) -> dict:
|
|
363
|
+
"""
|
|
364
|
+
解析 CloudQ SSE 流并构建结果。
|
|
365
|
+
|
|
366
|
+
响应字段为大驼峰:Content, IsFinal, ChatId, SessionId, Event, Error
|
|
367
|
+
输出统一为小写字段名的结果 dict。
|
|
368
|
+
|
|
369
|
+
SSE 流中可能包含错误事件(如权限不足),Error 字段格式:
|
|
370
|
+
{"Code": "UnauthorizedOperation", "Message": "..."}
|
|
371
|
+
|
|
372
|
+
异步模式(async_mode=True):
|
|
373
|
+
解析 Event="accepted" 的 data 帧,提取 ChatID + SessionID 后立即返回。
|
|
374
|
+
"""
|
|
375
|
+
content_parts = []
|
|
376
|
+
last_event_data = {}
|
|
377
|
+
request_id = ""
|
|
378
|
+
|
|
379
|
+
for raw_line in resp:
|
|
380
|
+
line = raw_line.decode("utf-8").rstrip("\r\n")
|
|
381
|
+
|
|
382
|
+
parsed = parse_sse_line(line)
|
|
383
|
+
if parsed is None:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if parsed.get("event") != "data":
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
data = parsed.get("data")
|
|
390
|
+
if not isinstance(data, dict):
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
# ---- 异步模式:处理 Event="accepted" 帧 ----
|
|
394
|
+
if async_mode and data.get("Event") == "accepted":
|
|
395
|
+
chat_id = data.get("ChatId", "")
|
|
396
|
+
resp_session_id = data.get("SessionId", session_id)
|
|
397
|
+
content = data.get("Content", "")
|
|
398
|
+
return _make_success(action, {
|
|
399
|
+
"chat_id": chat_id,
|
|
400
|
+
"session_id": resp_session_id,
|
|
401
|
+
"content": content,
|
|
402
|
+
"is_accepted": True,
|
|
403
|
+
}, chat_id)
|
|
404
|
+
|
|
405
|
+
if not request_id:
|
|
406
|
+
request_id = data.get("ChatId", "")
|
|
407
|
+
|
|
408
|
+
# 检查 SSE 流中的错误事件
|
|
409
|
+
error_info = data.get("Error")
|
|
410
|
+
if isinstance(error_info, dict) and error_info.get("Code"):
|
|
411
|
+
return _make_error(
|
|
412
|
+
action, error_info.get("Code", "Unknown"),
|
|
413
|
+
error_info.get("Message", "未知错误"),
|
|
414
|
+
request_id,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if on_event:
|
|
418
|
+
on_event(parsed)
|
|
419
|
+
|
|
420
|
+
content = data.get("Content", "")
|
|
421
|
+
if content:
|
|
422
|
+
content_parts.append(content)
|
|
423
|
+
last_event_data = data
|
|
424
|
+
|
|
425
|
+
# IsFinal=True 表示后端已推送完毕,主动断开不再等待
|
|
426
|
+
if data.get("IsFinal") is True:
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
raw_content = "".join(content_parts)
|
|
430
|
+
# 免密链接替换仅在 AK/SK 模式下执行(login_url.py 需要 sts:AssumeRole 权限,OAuth/Connector 没有)
|
|
431
|
+
if cred_source not in ("oauth", "connector"):
|
|
432
|
+
processed = _replace_console_urls(raw_content)
|
|
433
|
+
processed = _ensure_login_url(processed)
|
|
434
|
+
else:
|
|
435
|
+
processed = raw_content
|
|
436
|
+
# OAuth / Connector 模式下检测"未配置凭证"提示,引导用户到 CloudQ 控制台配置
|
|
437
|
+
if cred_source in ("oauth", "connector") and _is_credential_not_configured(processed):
|
|
438
|
+
processed += (
|
|
439
|
+
"\n\n请前往 [CloudQ 控制台](https://console.cloud.tencent.com/advisor/cloudq) "
|
|
440
|
+
"完成凭证配置后再使用。"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
merged = {
|
|
444
|
+
"session_id": session_id,
|
|
445
|
+
"content": processed,
|
|
446
|
+
"is_final": last_event_data.get("IsFinal", True),
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return _make_success(action, merged, request_id)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# CloudQ 凭证未配置检测(OAuth / Connector 模式下适用)
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
# CloudQChatCompletions 接口在用户未配置 CloudQ 凭证时返回的提示关键词
|
|
457
|
+
_CRED_NOT_CONFIGURED_KEYWORDS = [
|
|
458
|
+
"尚未配置腾讯云凭证",
|
|
459
|
+
"未配置腾讯云凭证",
|
|
460
|
+
"凭证设置",
|
|
461
|
+
"前往凭证设置",
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _is_credential_not_configured(content: str) -> bool:
|
|
466
|
+
"""检测 CloudQChatCompletions 返回的内容是否为"未配置凭证"提示"""
|
|
467
|
+
if not content:
|
|
468
|
+
return False
|
|
469
|
+
return any(kw in content for kw in _CRED_NOT_CONFIGURED_KEYWORDS)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
# 控制台链接 → 免密登录链接替换
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
# 匹配 console.cloud.tencent.com 的 URL(含路径和查询参数)
|
|
477
|
+
_CONSOLE_URL_RE = re.compile(r'https://console\.cloud\.tencent\.com[^\s\)\]"\']*')
|
|
478
|
+
# 提取 content 中的 archId(arch-开头)
|
|
479
|
+
_ARCH_ID_RE = re.compile(r'\barch-[a-z0-9]+\b')
|
|
480
|
+
# 免密登录链接特征(已替换过的不再处理)
|
|
481
|
+
_LOGIN_URL_MARKER = "cloud.tencent.com/login/roleAccessCallback"
|
|
482
|
+
# 不生成免密登录链接的路径(advisor/cloudq 需用户自行登录)
|
|
483
|
+
_SKIP_LOGIN_PATHS = re.compile(r'https://console\.cloud\.tencent\.com/advisor/cloudq(\?|/|$)')
|
|
484
|
+
|
|
485
|
+
# login_url.py 脚本路径
|
|
486
|
+
_LOGIN_SCRIPT = Path(__file__).resolve().parent / "login_url.py"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _generate_login_url(target_url: str) -> Optional[str]:
|
|
490
|
+
"""调用 login_url.py 生成免密登录链接,失败返回 None"""
|
|
491
|
+
try:
|
|
492
|
+
result = subprocess.run(
|
|
493
|
+
[sys.executable, str(_LOGIN_SCRIPT), target_url],
|
|
494
|
+
capture_output=True, text=True, timeout=30,
|
|
495
|
+
)
|
|
496
|
+
if result.returncode != 0:
|
|
497
|
+
return None
|
|
498
|
+
data = json.loads(result.stdout.strip())
|
|
499
|
+
if data.get("success"):
|
|
500
|
+
return data["data"]["loginUrl"]
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _append_hide_nav(url: str) -> str:
|
|
507
|
+
"""为控制台 URL 追加 hideTopNav=true 参数"""
|
|
508
|
+
if "hideTopNav=true" in url:
|
|
509
|
+
return url
|
|
510
|
+
sep = "&" if "?" in url else "?"
|
|
511
|
+
return f"{url}{sep}hideTopNav=true"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _enrich_url_with_arch_id(url: str, arch_id: str) -> str:
|
|
515
|
+
"""如果 URL 不含 archId 参数,自动追加第一个 archId"""
|
|
516
|
+
if "archId=" in url or not arch_id:
|
|
517
|
+
return url
|
|
518
|
+
sep = "&" if "?" in url else "?"
|
|
519
|
+
return f"{url}{sep}archId={arch_id}"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _extract_first_arch_id(content: str) -> str:
|
|
523
|
+
"""从 content 中提取第一个 archId"""
|
|
524
|
+
m = _ARCH_ID_RE.search(content)
|
|
525
|
+
return m.group(0) if m else ""
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _replace_console_urls(content: str) -> str:
|
|
529
|
+
"""
|
|
530
|
+
扫描 content 中所有控制台链接,替换为免密登录链接。
|
|
531
|
+
|
|
532
|
+
处理逻辑:
|
|
533
|
+
1. 跳过已是免密登录链接的 URL
|
|
534
|
+
2. 如果控制台链接不含 archId,但 content 中有 archId,自动拼入
|
|
535
|
+
3. 追加 hideTopNav 参数
|
|
536
|
+
4. 调用 login_url.py 生成免密链接替换
|
|
537
|
+
5. 生成失败时保留原链接
|
|
538
|
+
"""
|
|
539
|
+
if "console.cloud.tencent.com" not in content:
|
|
540
|
+
return content
|
|
541
|
+
urls = _CONSOLE_URL_RE.findall(content)
|
|
542
|
+
if not urls:
|
|
543
|
+
return content
|
|
544
|
+
|
|
545
|
+
first_arch_id = _extract_first_arch_id(content)
|
|
546
|
+
|
|
547
|
+
# 去重保序
|
|
548
|
+
seen = set()
|
|
549
|
+
unique_urls = []
|
|
550
|
+
for u in urls:
|
|
551
|
+
if u not in seen:
|
|
552
|
+
seen.add(u)
|
|
553
|
+
unique_urls.append(u)
|
|
554
|
+
|
|
555
|
+
for raw_url in unique_urls:
|
|
556
|
+
if _LOGIN_URL_MARKER in raw_url:
|
|
557
|
+
continue
|
|
558
|
+
if _SKIP_LOGIN_PATHS.match(raw_url):
|
|
559
|
+
continue
|
|
560
|
+
target = _append_hide_nav(raw_url)
|
|
561
|
+
target = _enrich_url_with_arch_id(target, first_arch_id)
|
|
562
|
+
login_url = _generate_login_url(target)
|
|
563
|
+
if login_url:
|
|
564
|
+
content = content.replace(raw_url, login_url)
|
|
565
|
+
return content
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _is_advisor_content(content: str) -> bool:
|
|
569
|
+
"""判断内容是否属于智能顾问场景(架构图、评估、巡检等)"""
|
|
570
|
+
advisor_keywords = [
|
|
571
|
+
"架构图", "架构目录", "架构详情", "架构评估", "风险评估",
|
|
572
|
+
"巡检", "智能顾问", "advisor", "ArchId", "archId",
|
|
573
|
+
"arch-", "评估项", "评估结果", "扫描", "架构健康",
|
|
574
|
+
]
|
|
575
|
+
lower = content.lower()
|
|
576
|
+
return any(kw.lower() in lower for kw in advisor_keywords)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _ensure_login_url(content: str) -> str:
|
|
580
|
+
"""
|
|
581
|
+
确保 content 中包含免密登录链接。
|
|
582
|
+
如果 content 不含任何免密链接,自动生成一个并追加到末尾。
|
|
583
|
+
|
|
584
|
+
排除规则:
|
|
585
|
+
- content 中已有 advisor/cloudq 链接(无需免密)则跳过
|
|
586
|
+
场景判断:
|
|
587
|
+
- 智能顾问场景: advisor?hideTopNav=true(有 archId 时追加)
|
|
588
|
+
- 非智能顾问场景: https://console.cloud.tencent.com/
|
|
589
|
+
"""
|
|
590
|
+
if not content or _LOGIN_URL_MARKER in content:
|
|
591
|
+
return content
|
|
592
|
+
|
|
593
|
+
# 已包含 advisor/cloudq 链接则跳过(该页面不需要免密)
|
|
594
|
+
if "console.cloud.tencent.com/advisor/cloudq" in content:
|
|
595
|
+
return content
|
|
596
|
+
|
|
597
|
+
first_arch_id = _extract_first_arch_id(content)
|
|
598
|
+
|
|
599
|
+
if _is_advisor_content(content) or first_arch_id:
|
|
600
|
+
base = "https://console.cloud.tencent.com/advisor?hideTopNav=true"
|
|
601
|
+
if first_arch_id:
|
|
602
|
+
target = f"{base}&archId={first_arch_id}"
|
|
603
|
+
else:
|
|
604
|
+
target = base
|
|
605
|
+
label = "前往智能顾问控制台"
|
|
606
|
+
else:
|
|
607
|
+
target = "https://console.cloud.tencent.com/"
|
|
608
|
+
label = "前往腾讯云控制台"
|
|
609
|
+
|
|
610
|
+
login_url = _generate_login_url(target)
|
|
611
|
+
if login_url:
|
|
612
|
+
content += f"\n\n[{label}]({login_url})"
|
|
613
|
+
return content
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _handle_http_error(e: HTTPError, action: str) -> dict:
|
|
617
|
+
"""处理 HTTP 错误响应"""
|
|
618
|
+
try:
|
|
619
|
+
body = e.read().decode("utf-8")
|
|
620
|
+
data = json.loads(body)
|
|
621
|
+
response = data.get("Response", {})
|
|
622
|
+
error = response.get("Error", {})
|
|
623
|
+
if error:
|
|
624
|
+
return _make_error(
|
|
625
|
+
action, error.get("Code", "HTTPError"),
|
|
626
|
+
error.get("Message", f"HTTP {e.code}"),
|
|
627
|
+
response.get("RequestId", ""),
|
|
628
|
+
)
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
return _make_error(
|
|
632
|
+
action, "HTTPError",
|
|
633
|
+
f"HTTP 请求失败 (状态码 {e.code}): {e.reason}"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _handle_non_sse_response(resp, action: str) -> dict:
|
|
638
|
+
"""处理非 SSE 响应(HTTP 200 但 Content-Type 非 event-stream,通常是 JSON 错误体)"""
|
|
639
|
+
try:
|
|
640
|
+
body = resp.read().decode("utf-8")
|
|
641
|
+
data = json.loads(body)
|
|
642
|
+
response = data.get("Response", {})
|
|
643
|
+
error = response.get("Error", {})
|
|
644
|
+
if error:
|
|
645
|
+
return _make_error(
|
|
646
|
+
action, error.get("Code", "Unknown"),
|
|
647
|
+
error.get("Message", "未知错误"),
|
|
648
|
+
response.get("RequestId", ""),
|
|
649
|
+
)
|
|
650
|
+
# 非错误但也非 SSE,返回原始数据
|
|
651
|
+
return _make_success(action, response, response.get("RequestId", ""))
|
|
652
|
+
except (json.JSONDecodeError, ValueError):
|
|
653
|
+
return _make_error(
|
|
654
|
+
action, "InvalidResponse",
|
|
655
|
+
f"接口返回非 SSE 格式且无法解析为 JSON: {body[:200]}"
|
|
656
|
+
)
|
|
657
|
+
except Exception as e:
|
|
658
|
+
return _make_error(action, "InvalidResponse", f"解析响应失败: {e}")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
# 命令行入口
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def _output_json(obj: dict) -> str:
|
|
666
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _parse_args(args: list) -> dict:
|
|
670
|
+
"""解析命令行参数,支持 --source / --session-id 标志位。
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
dict: {
|
|
674
|
+
"question": str,
|
|
675
|
+
"session_id": str | None, # 传入时非 None
|
|
676
|
+
"source": str,
|
|
677
|
+
}
|
|
678
|
+
"""
|
|
679
|
+
if not args:
|
|
680
|
+
return {}
|
|
681
|
+
|
|
682
|
+
result = {
|
|
683
|
+
"question": "",
|
|
684
|
+
"session_id": None,
|
|
685
|
+
"source": "",
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
# 第一个非标志位参数为 question
|
|
689
|
+
positional = []
|
|
690
|
+
i = 0
|
|
691
|
+
while i < len(args):
|
|
692
|
+
arg = args[i]
|
|
693
|
+
if arg == "--source":
|
|
694
|
+
if i + 1 < len(args):
|
|
695
|
+
result["source"] = args[i + 1]
|
|
696
|
+
i += 1
|
|
697
|
+
elif arg.startswith("--source="):
|
|
698
|
+
result["source"] = arg[len("--source="):]
|
|
699
|
+
elif arg == "--session-id":
|
|
700
|
+
if i + 1 < len(args):
|
|
701
|
+
result["session_id"] = args[i + 1]
|
|
702
|
+
i += 1
|
|
703
|
+
elif arg.startswith("--session-id="):
|
|
704
|
+
result["session_id"] = arg[len("--session-id="):]
|
|
705
|
+
elif not result["question"]:
|
|
706
|
+
result["question"] = arg
|
|
707
|
+
positional.append(arg)
|
|
708
|
+
else:
|
|
709
|
+
positional.append(arg)
|
|
710
|
+
i += 1
|
|
711
|
+
|
|
712
|
+
# 兼容旧用法:位置参数第2个为 session_id,第3个为 source
|
|
713
|
+
if len(positional) >= 2:
|
|
714
|
+
result["session_id"] = positional[1] if positional[1] else None
|
|
715
|
+
if len(positional) >= 3 and not result["source"]:
|
|
716
|
+
result["source"] = positional[2]
|
|
717
|
+
|
|
718
|
+
return result
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def main():
|
|
722
|
+
"""命令行入口:python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid>"""
|
|
723
|
+
# 锁定 stdout/stderr 为 UTF-8
|
|
724
|
+
import io
|
|
725
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
726
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
727
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
728
|
+
else:
|
|
729
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
730
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
731
|
+
|
|
732
|
+
parsed = _parse_args(sys.argv[1:])
|
|
733
|
+
if not parsed or not parsed.get("question"):
|
|
734
|
+
usage = (
|
|
735
|
+
"用法: python3 tcloud_sse_api.py <question> --source <platform> "
|
|
736
|
+
"--session-id <uuid>"
|
|
737
|
+
)
|
|
738
|
+
print(_output_json(_make_error(ACTION, "MissingParameter", usage)))
|
|
739
|
+
sys.exit(1)
|
|
740
|
+
|
|
741
|
+
question = parsed["question"]
|
|
742
|
+
source = parsed["source"]
|
|
743
|
+
|
|
744
|
+
# ---- SessionID ----
|
|
745
|
+
if not parsed["session_id"]:
|
|
746
|
+
print(_output_json(_make_error(
|
|
747
|
+
ACTION, "MissingParameter",
|
|
748
|
+
"缺少必填参数 --session-id。"
|
|
749
|
+
"请通过 SID=$(python3 -c 'import uuid;print(uuid.uuid4())') 生成后传入。"
|
|
750
|
+
)))
|
|
751
|
+
sys.exit(1)
|
|
752
|
+
session_id = parsed["session_id"]
|
|
753
|
+
|
|
754
|
+
# 回显 session_id 到 stderr,供调用方追问时复用
|
|
755
|
+
print(f"[session] {session_id}", file=sys.stderr, flush=True)
|
|
756
|
+
|
|
757
|
+
result = call_sse_api(question, session_id, source=source)
|
|
758
|
+
|
|
759
|
+
print(_output_json(result))
|
|
760
|
+
if not result.get("success"):
|
|
761
|
+
sys.exit(1)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
if __name__ == "__main__":
|
|
765
|
+
main()
|