refactor(sdk): drop data-jcid, switch to semantic anchors + ClawX testids
- Introduce centralized SELECTOR constant table at top of client.py
- Replace all [data-jcid=...] selectors with:
* Semantic anchors (data-role / data-message-id / data-streaming /
data-sending / data-message-count) guarded by jiangchang main repo
* ClawX upstream data-testids (chat-page / chat-composer-* / etc.)
- _latest_assistant_after: has_body now judged by inner_text non-empty
(no longer depends on message-body sub-element)
- _gateway_state: degrade to window.__jc_gateway_state__ readback,
returns 'unknown' until host exposes it
- new_task: rely solely on data-message-count==0 (welcome-screen no
longer has a testid)
- wait_gateway_ready: degrade to chat-page selector wait
- send_file: raise NotImplementedError (Electron IPC hook missing)
Public API signatures unchanged. window.__jc_sending__ remains the
core sending signal.
Compatibility: jiangchang v2.0.17+ only.
Refs: jiangchang/docs/sdk-migration-discovery.md
jiangchang/docs/JIANGCHANG_CUSTOM_ANCHORS.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
54
README.md
54
README.md
@@ -63,3 +63,57 @@ git push origin v0.1.0
|
|||||||
```
|
```
|
||||||
|
|
||||||
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。
|
仓库内还保留 `examples/`、`tools/`、`python-runtime/` 目录及既有 Skill / 前端相关的 GitHub Actions 工作流,与本 PyPI 包的发布彼此独立。
|
||||||
|
|
||||||
|
## v0.2.0 — data-jcid migration (internal refactor)
|
||||||
|
|
||||||
|
### TL;DR
|
||||||
|
|
||||||
|
本次内部重构:SDK 不再依赖匠厂主仓库的 `data-jcid` 属性。
|
||||||
|
所有公开 API(`ask` / `read` / `new_task` / `wait_gateway_ready` 等)
|
||||||
|
**签名与行为保持不变**,只有内部选择器实现切换到新的语义锚点 + ClawX testid。
|
||||||
|
|
||||||
|
### 改变了什么
|
||||||
|
|
||||||
|
- `client.py` 内所有 `[data-jcid="..."]` 选择器已移除
|
||||||
|
- 改用匠厂主仓库为 SDK 承诺的语义锚点:`data-role` / `data-message-id` /
|
||||||
|
`data-streaming`(在 ChatMessage 上)、`data-sending` / `data-message-count`
|
||||||
|
(在 chat-page 根上)
|
||||||
|
- `window.__jc_sending__` 仍是核心 sending 信号,保持不变
|
||||||
|
- 这些锚点由匠厂主仓库的以下机制守护:
|
||||||
|
- `docs/JIANGCHANG_CUSTOM_ANCHORS.md` 中央清单
|
||||||
|
- 源码内 `JIANGCHANG CUSTOM ANCHOR` 边界注释
|
||||||
|
- `harness/specs/jiangchang-custom-anchors.spec.ts` E2E 守护测试
|
||||||
|
- Gitea CI grep 检查 step
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- `send_file()` 暂未实现,抛 `NotImplementedError`。
|
||||||
|
原因:附件上传走 Electron IPC,SDK 需要主仓库提供新 hook。
|
||||||
|
- `_gateway_state()` 永远返回 `'unknown'`。
|
||||||
|
原因:gateway 状态不再暴露 DOM data-state;本方法已预留未来通过
|
||||||
|
`window.__jc_gateway_state__` 读取的实现路径。
|
||||||
|
|
||||||
|
这两个限制对常规 ask/read 工作流**无影响**,sending 完成判定依靠
|
||||||
|
`window.__jc_sending__` + `data-sending` + 文本稳定性合流即可。
|
||||||
|
|
||||||
|
### 升级影响
|
||||||
|
|
||||||
|
- 在 jiangchang v2.0.17(含)以上版本上跑:应正常工作
|
||||||
|
- 在 jiangchang v2.0.16(及更早含 data-jcid 的版本)上跑:不再兼容
|
||||||
|
如果需要在旧版本上跑,固定 SDK 版本到 v0.1.x
|
||||||
|
|
||||||
|
### 验证清单(集成方建议跑一次)
|
||||||
|
|
||||||
|
打开匠厂客户端 → 用 SDK 跑下面 3 步,确认基础流程通:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jiangchang_desktop_sdk import JiangchangDesktopClient
|
||||||
|
|
||||||
|
with JiangchangDesktopClient() as c:
|
||||||
|
c.ensure_app_running()
|
||||||
|
c.wait_gateway_ready()
|
||||||
|
c.new_task()
|
||||||
|
answer = c.ask("你好,请回复确认 SDK 可用")
|
||||||
|
assert answer, f"Empty answer: {answer!r}"
|
||||||
|
print("OK:", answer[:200])
|
||||||
|
```
|
||||||
|
|||||||
@@ -54,6 +54,42 @@ from .exceptions import (
|
|||||||
)
|
)
|
||||||
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JIANGCHANG DOM SELECTORS
|
||||||
|
# ============================================================================
|
||||||
|
# 本节集中维护所有 Playwright 选择器,作为对匠厂前端的契约。
|
||||||
|
# 选择器分两类:
|
||||||
|
#
|
||||||
|
# (A) 主仓库语义锚点(由 docs/JIANGCHANG_CUSTOM_ANCHORS.md 守护,稳定)
|
||||||
|
# 这些是匠厂为 SDK 暴露的承诺契约,被 E2E 守护测试 + Gitea CI 双重保护。
|
||||||
|
#
|
||||||
|
# (B) 主仓库 ClawX 上游 data-testid(随上游同步可能漂移)
|
||||||
|
# 这些是 ClawX 上游或我们扩展的测试钩子,本身用于内部 E2E,
|
||||||
|
# SDK 借用但不保证不变。
|
||||||
|
#
|
||||||
|
# 修改前请确认 docs/sdk-migration-discovery.md 中对应元素仍存在。
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# --- (A) 匠厂语义锚点 ---
|
||||||
|
SEL_CHAT_PAGE = '[data-testid="chat-page"]'
|
||||||
|
SEL_CHAT_PAGE_SENDING = '[data-testid="chat-page"][data-sending="true"]'
|
||||||
|
SEL_MESSAGE_ROW = '[data-role]' # ChatMessage 行容器
|
||||||
|
SEL_MESSAGE_ROW_USER = '[data-role="user"]'
|
||||||
|
SEL_MESSAGE_ROW_ASSISTANT = '[data-role="assistant"]'
|
||||||
|
SEL_MESSAGE_ROW_STREAMING = '[data-streaming="true"]'
|
||||||
|
|
||||||
|
# --- (B) ClawX/上游 data-testid ---
|
||||||
|
SEL_SIDEBAR_NEW_CHAT = '[data-testid="sidebar-new-chat"]'
|
||||||
|
SEL_CHAT_COMPOSER_INPUT = '[data-testid="chat-composer-input"]'
|
||||||
|
SEL_CHAT_COMPOSER_SEND = '[data-testid="chat-composer-send"]'
|
||||||
|
SEL_CHAT_MESSAGE_INDEXED = '[data-testid^="chat-message-"]' # chat-message-${idx}
|
||||||
|
SEL_EXECUTION_GRAPH = '[data-testid="chat-execution-graph"]'
|
||||||
|
SEL_EXECUTION_GRAPH_EXPANDED = '[data-testid="chat-execution-graph"][data-collapsed="false"]'
|
||||||
|
SEL_MAIN_LAYOUT = '[data-testid="main-layout"]'
|
||||||
|
|
||||||
|
# --- (C) 通用兜底(无 testid 时使用) ---
|
||||||
|
SEL_FALLBACK_TEXTAREA = 'textarea'
|
||||||
|
|
||||||
# ── 调试日志 ───────────────────────────────────────────────────────
|
# ── 调试日志 ───────────────────────────────────────────────────────
|
||||||
_logger = logging.getLogger("jiangchang_desktop_sdk")
|
_logger = logging.getLogger("jiangchang_desktop_sdk")
|
||||||
_handler_added = False
|
_handler_added = False
|
||||||
@@ -375,51 +411,46 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _get_chat_input_locator(self):
|
def _get_chat_input_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="chat-input"]')
|
locator = page.locator(SEL_CHAT_COMPOSER_INPUT)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
locator = page.locator("textarea").last
|
locator = page.locator(SEL_FALLBACK_TEXTAREA).last
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
raise ConnectionError("找不到聊天输入框(textarea),请确认匠厂已加 data-jcid=\"chat-input\"")
|
raise ConnectionError(
|
||||||
|
'找不到聊天输入框,请确认匠厂已加 data-testid="chat-composer-input" '
|
||||||
|
"或页面中存在 textarea"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_send_button_locator(self):
|
def _get_send_button_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="send-button"]')
|
locator = page.locator(SEL_CHAT_COMPOSER_SEND)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
locator = page.locator('button[type="submit"]')
|
locator = page.locator('button[type="submit"]')
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
raise ConnectionError("找不到发送按钮,请确认匠厂已加 data-jcid=\"send-button\"")
|
raise ConnectionError(
|
||||||
|
'找不到发送按钮,请确认匠厂已加 data-testid="chat-composer-send" '
|
||||||
|
'或 button[type="submit"]'
|
||||||
|
)
|
||||||
|
|
||||||
def _get_new_task_button_locator(self):
|
def _get_new_task_button_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="new-task-button"]')
|
locator = page.locator(SEL_SIDEBAR_NEW_CHAT)
|
||||||
if locator.count() > 0:
|
if locator.count() > 0:
|
||||||
return locator
|
return locator
|
||||||
# 回退:旧版本 data-testid
|
|
||||||
locator = page.locator('[data-testid="sidebar-new-chat"]')
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
# 最后兜底:文本"新建任务"
|
|
||||||
locator = page.get_by_text("新建任务", exact=False).first
|
locator = page.get_by_text("新建任务", exact=False).first
|
||||||
return locator
|
return locator
|
||||||
|
|
||||||
def _get_message_list_locator(self):
|
def _get_message_list_locator(self):
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
locator = page.locator('[data-jcid="message-list"]')
|
return page.locator(SEL_CHAT_PAGE)
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
locator = page.locator('[class*="space-y-3"]').first
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
raise ConnectionError("找不到消息列表容器 data-jcid=\"message-list\"")
|
|
||||||
|
|
||||||
# ─── 新建任务 ─────────────────────────────────────────────────
|
# ─── 新建任务 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def new_task(self) -> None:
|
def new_task(self) -> None:
|
||||||
"""点击"新建任务"按钮,等待消息列表清空或欢迎屏出现。"""
|
"""点击"新建任务"按钮,等待消息列表清空(chat-page data-message-count=0)。"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
raise ConnectionError("未连接到应用")
|
raise ConnectionError("未连接到应用")
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
@@ -431,16 +462,12 @@ class JiangchangDesktopClient:
|
|||||||
_logger.warning("[new_task] 点击失败:%s", exc)
|
_logger.warning("[new_task] 点击失败:%s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 等待 chat-root.message-count=0 或 welcome-screen 出现,最多 5s
|
|
||||||
deadline = time.time() + 5
|
deadline = time.time() + 5
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
count = page.locator('[data-jcid="chat-root"]').first.get_attribute("data-jcid-message-count")
|
count = page.locator(SEL_CHAT_PAGE).first.get_attribute("data-message-count")
|
||||||
if count == "0":
|
if count == "0":
|
||||||
_logger.debug("[new_task] 已进入新任务(message-count=0)")
|
_logger.debug("[new_task] 已进入新任务(data-message-count=0)")
|
||||||
return
|
|
||||||
if page.locator('[data-jcid="welcome-screen"]').count() > 0:
|
|
||||||
_logger.debug("[new_task] 欢迎屏已出现")
|
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -450,25 +477,35 @@ class JiangchangDesktopClient:
|
|||||||
# ─── 网关健康检查 ─────────────────────────────────────────────
|
# ─── 网关健康检查 ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _gateway_state(self) -> str:
|
def _gateway_state(self) -> str:
|
||||||
|
"""Read gateway state via Electron IPC bridge (jiangchang exposes no DOM data-state).
|
||||||
|
|
||||||
|
Strategy: try to read window.__jc_gateway_state__ if exposed;
|
||||||
|
otherwise return 'unknown' and let upper logic continue.
|
||||||
|
"""
|
||||||
|
# TODO: 等匠厂在 src/stores/gateway/* 或 main 进程暴露
|
||||||
|
# window.__jc_gateway_state__ 之后,本方法将自动开始返回真实状态。
|
||||||
|
# 当前永远返回 'unknown',行为退化为"假定 gateway 健康"。
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
state = self.get_page().evaluate(
|
||||||
el = page.locator('[data-jcid="gateway-status"]').first
|
"() => window.__jc_gateway_state__"
|
||||||
if el.count() == 0:
|
)
|
||||||
return "unknown"
|
if isinstance(state, str) and state:
|
||||||
return el.get_attribute("data-state") or "unknown"
|
return state
|
||||||
except Exception:
|
except Exception:
|
||||||
return "unknown"
|
pass
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def wait_gateway_ready(self, timeout_ms: int = 30000) -> None:
|
def wait_gateway_ready(self, timeout_ms: int = 30000) -> None:
|
||||||
|
"""等待匠厂前端就绪。
|
||||||
|
|
||||||
|
由于 v2.0.17 起 gateway 状态不再暴露 DOM data-state,本方法降级为
|
||||||
|
等待 chat-page 根元素可见(隐含 React + gateway store 已初始化)。
|
||||||
|
Gateway 实际就绪与否,由后续 ask() 内的 _gateway_state 检查兜底
|
||||||
|
(当前永远返回 'unknown')。
|
||||||
|
"""
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
try:
|
try:
|
||||||
page.wait_for_function(
|
page.wait_for_selector(SEL_CHAT_PAGE, timeout=timeout_ms)
|
||||||
"""() => {
|
|
||||||
const el = document.querySelector('[data-jcid="gateway-status"]');
|
|
||||||
return el && el.dataset.state === 'running';
|
|
||||||
}""",
|
|
||||||
timeout=timeout_ms,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning("[wait_gateway_ready] 超时/异常:%s(继续,但后续可能报 GatewayDown)", exc)
|
_logger.warning("[wait_gateway_ready] 超时/异常:%s(继续,但后续可能报 GatewayDown)", exc)
|
||||||
|
|
||||||
@@ -481,7 +518,7 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _message_node_count(self) -> int:
|
def _message_node_count(self) -> int:
|
||||||
try:
|
try:
|
||||||
return self.get_page().locator('[data-jcid="message"]').count()
|
return self.get_page().locator(SEL_MESSAGE_ROW).count()
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -489,10 +526,10 @@ class JiangchangDesktopClient:
|
|||||||
"""返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。"""
|
"""返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。"""
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
total = page.locator('[data-jcid="message"]').count()
|
total = page.locator(SEL_MESSAGE_ROW).count()
|
||||||
for i in range(total - 1, -1, -1):
|
for i in range(total - 1, -1, -1):
|
||||||
node = page.locator('[data-jcid="message"]').nth(i)
|
node = page.locator(SEL_MESSAGE_ROW).nth(i)
|
||||||
role = node.get_attribute("data-jcid-role") or ""
|
role = node.get_attribute("data-role") or ""
|
||||||
if role.lower() == "user":
|
if role.lower() == "user":
|
||||||
return i
|
return i
|
||||||
return -1
|
return -1
|
||||||
@@ -507,7 +544,7 @@ class JiangchangDesktopClient:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
node = page.locator('[data-jcid="message"]').nth(user_idx)
|
node = page.locator(SEL_MESSAGE_ROW).nth(user_idx)
|
||||||
text = node.inner_text(timeout=2000) or ""
|
text = node.inner_text(timeout=2000) or ""
|
||||||
t = re.sub(r"\s+", "", text)
|
t = re.sub(r"\s+", "", text)
|
||||||
q = re.sub(r"\s+", "", question)
|
q = re.sub(r"\s+", "", question)
|
||||||
@@ -529,22 +566,19 @@ class JiangchangDesktopClient:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
nodes = page.locator('[data-jcid="message"]')
|
nodes = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = nodes.count()
|
total = nodes.count()
|
||||||
for i in range(total - 1, user_idx, -1):
|
for i in range(total - 1, user_idx, -1):
|
||||||
node = nodes.nth(i)
|
node = nodes.nth(i)
|
||||||
role = (node.get_attribute("data-jcid-role") or "").lower()
|
role = (node.get_attribute("data-role") or "").lower()
|
||||||
if role != "assistant":
|
if role != "assistant":
|
||||||
continue
|
continue
|
||||||
body = node.locator('[data-jcid="message-body"]')
|
try:
|
||||||
has_body = body.count() > 0
|
body_text = node.inner_text(timeout=2000) or ""
|
||||||
body_text = ""
|
except Exception:
|
||||||
if has_body:
|
body_text = ""
|
||||||
try:
|
has_body = len(body_text.strip()) > 0
|
||||||
body_text = body.first.inner_text(timeout=2000) or ""
|
streaming = (node.get_attribute("data-streaming") or "false").lower() == "true"
|
||||||
except Exception:
|
|
||||||
body_text = ""
|
|
||||||
streaming = (node.get_attribute("data-jcid-streaming") or "false").lower() == "true"
|
|
||||||
return {
|
return {
|
||||||
"index": i,
|
"index": i,
|
||||||
"has_body": has_body,
|
"has_body": has_body,
|
||||||
@@ -575,8 +609,8 @@ class JiangchangDesktopClient:
|
|||||||
def _chat_root_sending(self) -> Optional[bool]:
|
def _chat_root_sending(self) -> Optional[bool]:
|
||||||
"""chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。"""
|
"""chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。"""
|
||||||
try:
|
try:
|
||||||
val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute(
|
val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute(
|
||||||
"data-jcid-sending"
|
"data-sending"
|
||||||
)
|
)
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
@@ -586,8 +620,8 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
def _chat_root_message_count(self) -> Optional[int]:
|
def _chat_root_message_count(self) -> Optional[int]:
|
||||||
try:
|
try:
|
||||||
val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute(
|
val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute(
|
||||||
"data-jcid-message-count"
|
"data-message-count"
|
||||||
)
|
)
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
@@ -626,16 +660,16 @@ class JiangchangDesktopClient:
|
|||||||
"""
|
"""
|
||||||
等待 agent 整轮(可能包含多次 thinking / tool_use 往返)完成。
|
等待 agent 整轮(可能包含多次 thinking / tool_use 往返)完成。
|
||||||
|
|
||||||
核心判据 —— 只有 agent 最终答复才有 `message-body`(文本气泡):
|
核心判据 —— agent 最终答复在 data-role=assistant 行上有非空 inner_text:
|
||||||
源码依据(D:\\AI\\jiangchang/src/stores/chat/helpers.ts):
|
源码依据(D:\\AI\\jiangchang/src/stores/chat/helpers.ts):
|
||||||
- isToolOnlyMessage 显式允许 thinking 与 tool_use 共存仍视为中间轮
|
- isToolOnlyMessage 显式允许 thinking 与 tool_use 共存仍视为中间轮
|
||||||
- 中间工具轮的 ChatMessage 无 hasText → 不渲染 MessageBubble →
|
- 中间工具轮可能无可见正文气泡 → data-role 行 inner_text 为空
|
||||||
也就不会有 `data-jcid="message-body"`
|
- 只有最终含 text 的助手消息行 inner_text 非空(has_body)
|
||||||
- 只有最终含 text 的助手消息才会渲染 message-body
|
|
||||||
|
|
||||||
因此可靠的完成条件为:
|
因此可靠的完成条件为:
|
||||||
(A)最后一条 assistant message 存在 [data-jcid="message-body"];
|
(A)user_msg_index 之后最后一条 assistant 的 has_body 为 True
|
||||||
(B)其文本连续 `stable_seconds` 秒无变化;
|
(由 _latest_assistant_after 根据 inner_text 判定);
|
||||||
|
(B)其 body_text 连续 `stable_seconds` 秒无变化;
|
||||||
(C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍
|
(C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍
|
||||||
早期 React 未挂载注入钩子的情况)。
|
早期 React 未挂载注入钩子的情况)。
|
||||||
|
|
||||||
@@ -675,10 +709,10 @@ class JiangchangDesktopClient:
|
|||||||
assistant = self._latest_assistant_after(user_msg_index)
|
assistant = self._latest_assistant_after(user_msg_index)
|
||||||
|
|
||||||
# 完成判据需同时满足:
|
# 完成判据需同时满足:
|
||||||
# (1) 三路 sending 信号全部静默(chat-root / window / 未收起的 execution graph)
|
# (1) 三路 sending 信号全部静默(chat-page data-sending / window / 未收起的 execution graph)
|
||||||
# (2) user_msg_index 之后存在 assistant 消息
|
# (2) user_msg_index 之后存在 assistant 消息(data-role 行)
|
||||||
# (3) 该 assistant 存在 message-body
|
# (3) 该 assistant has_body 为 True(_latest_assistant_after,inner_text 非空)
|
||||||
# (4) 该 assistant 的 data-jcid-streaming != 'true'
|
# (4) 该 assistant 的 data-streaming != 'true'
|
||||||
# (5) body 文本连续 stable_seconds 秒无变化
|
# (5) body 文本连续 stable_seconds 秒无变化
|
||||||
can_check_stable = (
|
can_check_stable = (
|
||||||
not sending_any
|
not sending_any
|
||||||
@@ -740,7 +774,7 @@ class JiangchangDesktopClient:
|
|||||||
|
|
||||||
raise JcTimeoutError(
|
raise JcTimeoutError(
|
||||||
f"等待 AI 回复超时({timeout_ms}ms)。user_msg_index={user_msg_index} 最后阶段 phase={phase}。"
|
f"等待 AI 回复超时({timeout_ms}ms)。user_msg_index={user_msg_index} 最后阶段 phase={phase}。"
|
||||||
"可能原因:模型思考时间过长 / 工具链循环未结束 / UI 未挂载 message-body / Gateway 卡住。"
|
"可能原因:模型思考时间过长 / 工具链循环未结束 / assistant 正文未出现(data-role 行 inner_text 为空)/ Gateway 卡住。"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── 核心交互:ask / read / assert ────────────────────────────
|
# ─── 核心交互:ask / read / assert ────────────────────────────
|
||||||
@@ -844,70 +878,56 @@ class JiangchangDesktopClient:
|
|||||||
poll_interval=opts.poll_interval,
|
poll_interval=opts.poll_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. 选答:只在 my_user_idx 之后的 assistant 中找带 message-body 的最后一条
|
# 6. 选答:只在 my_user_idx 之后的 assistant 中找 has_body 的最后一条
|
||||||
picked = self._latest_assistant_after(my_user_idx)
|
picked = self._latest_assistant_after(my_user_idx)
|
||||||
if picked is None or not picked["has_body"]:
|
if picked is None or not picked["has_body"]:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"[ask] user_idx=%d 之后未找到带 message-body 的 assistant(picked=%s)",
|
"[ask] user_idx=%d 之后未找到 has_body 的 assistant(picked=%s)",
|
||||||
my_user_idx, picked,
|
my_user_idx, picked,
|
||||||
)
|
)
|
||||||
# 兜底:取我们 user 之后的任意最后一条 assistant inner_text
|
# 兜底:取我们 user 之后的任意最后一条 assistant inner_text
|
||||||
try:
|
try:
|
||||||
page = self.get_page()
|
page = self.get_page()
|
||||||
nodes = page.locator('[data-jcid="message"]')
|
nodes = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = nodes.count()
|
total = nodes.count()
|
||||||
for i in range(total - 1, my_user_idx, -1):
|
for i in range(total - 1, my_user_idx, -1):
|
||||||
node = nodes.nth(i)
|
node = nodes.nth(i)
|
||||||
if (node.get_attribute("data-jcid-role") or "").lower() == "assistant":
|
if (node.get_attribute("data-role") or "").lower() == "assistant":
|
||||||
return node.inner_text(timeout=2000) or ""
|
return node.inner_text(timeout=2000) or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
"[ask] 选定 assistant_idx=%d(user_idx=%d 之后第一条带 message-body)len=%d",
|
"[ask] 选定 assistant_idx=%d(user_idx=%d 之后 has_body)len=%d",
|
||||||
picked["index"], my_user_idx, len(picked["body_text"]),
|
picked["index"], my_user_idx, len(picked["body_text"]),
|
||||||
)
|
)
|
||||||
return picked["body_text"]
|
return picked["body_text"]
|
||||||
|
|
||||||
def send_file(self, file_path: str, message: Optional[str] = None) -> None:
|
def send_file(self, file_path: str, message: Optional[str] = None) -> None:
|
||||||
if not self._connected:
|
"""[暂未实现] 发送文件附件。
|
||||||
raise ConnectionError("未连接到应用")
|
|
||||||
page = self.get_page()
|
|
||||||
|
|
||||||
attach_btn = page.locator('[data-jcid="attach-button"]')
|
匠厂 v2.0.17(ClawX v0.4.3 合并后)的附件上传通道由原生 Electron
|
||||||
if attach_btn.count() == 0:
|
showOpenDialog + IPC stage-paths 实现,不再暴露 HTML <input type="file">。
|
||||||
attach_btn = page.locator("button").filter(
|
|
||||||
has_text=re.compile(r"附件|上传|paperclip|attach", re.I)
|
|
||||||
)
|
|
||||||
|
|
||||||
file_input = page.locator('input[type="file"]')
|
Playwright 无法直接触发 Electron 原生对话框。需要匠厂主仓库新增:
|
||||||
if file_input.count() == 0 and attach_btn.count() > 0:
|
- window.__jc_stage_paths__(paths: string[]) 调用桥,或
|
||||||
attach_btn.first.click()
|
- 一个隐藏 <input type="file"> 备用通道,带 data-testid
|
||||||
time.sleep(0.5)
|
|
||||||
file_input = page.locator('input[type="file"]')
|
|
||||||
|
|
||||||
if file_input.count() == 0:
|
在匠厂提供其中之一之前,本方法抛 NotImplementedError 而非静默成功。
|
||||||
raise AssertError(
|
|
||||||
"找不到文件上传 input[type='file']。请确认匠厂已加 data-jcid=\"attach-button\"。"
|
|
||||||
)
|
|
||||||
|
|
||||||
abs_path = os.path.abspath(file_path)
|
Tracking: 见 docs/sdk-migration-discovery.md §7
|
||||||
if not os.path.exists(abs_path):
|
"""
|
||||||
raise AppNotFoundError(f"上传文件不存在: {abs_path}")
|
raise NotImplementedError(
|
||||||
|
"send_file is temporarily unavailable on jiangchang v2.0.17+. "
|
||||||
file_input.set_input_files(abs_path)
|
"Reason: attachments now go through Electron IPC; SDK requires "
|
||||||
time.sleep(1)
|
"a new DOM-or-window hook from the host application. "
|
||||||
|
"See docs/sdk-migration-discovery.md §7."
|
||||||
if message:
|
)
|
||||||
input_locator = self._get_chat_input_locator()
|
|
||||||
input_locator.click()
|
|
||||||
input_locator.press_sequentially(message, delay=25)
|
|
||||||
input_locator.press("Enter")
|
|
||||||
|
|
||||||
def read(self) -> List[JiangchangMessage]:
|
def read(self) -> List[JiangchangMessage]:
|
||||||
"""
|
"""
|
||||||
严格读取:仅接受带 data-jcid="message" + data-jcid-role 的节点。
|
严格读取:仅接受带 data-role 语义锚点的 ChatMessage 行节点。
|
||||||
无 role 的节点返回 role='unknown'(不再默认为 assistant)。
|
无 role 的节点返回 role='unknown'(不再默认为 assistant)。
|
||||||
"""
|
"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
@@ -917,41 +937,35 @@ class JiangchangDesktopClient:
|
|||||||
messages: List[JiangchangMessage] = []
|
messages: List[JiangchangMessage] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg_list = self._get_message_list_locator()
|
page.locator(SEL_CHAT_PAGE).wait_for(state="visible", timeout=5000)
|
||||||
msg_list.wait_for(state="visible", timeout=5000)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("[read] 消息列表不可见:%s", exc)
|
_logger.debug("[read] 聊天页不可见:%s", exc)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
items = page.locator('[data-jcid="message"]')
|
items = page.locator(SEL_MESSAGE_ROW)
|
||||||
total = items.count()
|
total = items.count()
|
||||||
_logger.debug("[read] data-jcid=\"message\" 节点数=%d", total)
|
_logger.debug("[read] data-role 消息行数=%d", total)
|
||||||
|
|
||||||
for i in range(total):
|
for i in range(total):
|
||||||
el = items.nth(i)
|
el = items.nth(i)
|
||||||
try:
|
try:
|
||||||
role_attr = el.get_attribute("data-jcid-role")
|
role_attr = el.get_attribute("data-role")
|
||||||
streaming_attr = el.get_attribute("data-jcid-streaming") or "false"
|
streaming_attr = el.get_attribute("data-streaming") or "false"
|
||||||
# 优先从 message-body(正文气泡)取干净文本,避免带入 Thinking / ToolCards
|
content = el.inner_text(timeout=2000)
|
||||||
body_locator = el.locator('[data-jcid="message-body"]')
|
|
||||||
if body_locator.count() > 0:
|
|
||||||
content = body_locator.first.inner_text(timeout=2000)
|
|
||||||
else:
|
|
||||||
content = el.inner_text(timeout=2000)
|
|
||||||
|
|
||||||
role = (role_attr or "unknown").lower()
|
role = (role_attr or "unknown").lower()
|
||||||
|
tool_status = "streaming" if streaming_attr == "true" else None
|
||||||
messages.append(
|
messages.append(
|
||||||
JiangchangMessage(
|
JiangchangMessage(
|
||||||
id=el.get_attribute("data-jcid-message-id") or f"msg-{i}",
|
id=el.get_attribute("data-message-id") or f"msg-{i}",
|
||||||
role=role,
|
role=role,
|
||||||
content=content,
|
content=content,
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
is_error=(el.get_attribute("data-jcid-error") == "true"),
|
is_error=False,
|
||||||
tool_name=el.get_attribute("data-jcid-tool"),
|
tool_name=None,
|
||||||
|
tool_status=tool_status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if streaming_attr == "true":
|
|
||||||
messages[-1].tool_status = "streaming"
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.debug("[read] 读取第%d条异常:%s", i, exc)
|
_logger.debug("[read] 读取第%d条异常:%s", i, exc)
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user