diff --git a/README.md b/README.md index 6b015ff..3d312a2 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,57 @@ git push origin v0.1.0 ``` 仓库内还保留 `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]) +``` diff --git a/src/jiangchang_desktop_sdk/client.py b/src/jiangchang_desktop_sdk/client.py index 45d376b..eff4434 100644 --- a/src/jiangchang_desktop_sdk/client.py +++ b/src/jiangchang_desktop_sdk/client.py @@ -54,6 +54,42 @@ from .exceptions import ( ) 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") _handler_added = False @@ -375,51 +411,46 @@ class JiangchangDesktopClient: def _get_chat_input_locator(self): page = self.get_page() - locator = page.locator('[data-jcid="chat-input"]') + locator = page.locator(SEL_CHAT_COMPOSER_INPUT) if locator.count() > 0: return locator - locator = page.locator("textarea").last + locator = page.locator(SEL_FALLBACK_TEXTAREA).last if locator.count() > 0: return locator - raise ConnectionError("找不到聊天输入框(textarea),请确认匠厂已加 data-jcid=\"chat-input\"") + raise ConnectionError( + '找不到聊天输入框,请确认匠厂已加 data-testid="chat-composer-input" ' + "或页面中存在 textarea" + ) def _get_send_button_locator(self): page = self.get_page() - locator = page.locator('[data-jcid="send-button"]') + locator = page.locator(SEL_CHAT_COMPOSER_SEND) if locator.count() > 0: return locator locator = page.locator('button[type="submit"]') if locator.count() > 0: 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): page = self.get_page() - locator = page.locator('[data-jcid="new-task-button"]') + locator = page.locator(SEL_SIDEBAR_NEW_CHAT) if locator.count() > 0: 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 return locator def _get_message_list_locator(self): page = self.get_page() - locator = page.locator('[data-jcid="message-list"]') - 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\"") + return page.locator(SEL_CHAT_PAGE) # ─── 新建任务 ───────────────────────────────────────────────── def new_task(self) -> None: - """点击"新建任务"按钮,等待消息列表清空或欢迎屏出现。""" + """点击"新建任务"按钮,等待消息列表清空(chat-page data-message-count=0)。""" if not self._connected: raise ConnectionError("未连接到应用") page = self.get_page() @@ -431,16 +462,12 @@ class JiangchangDesktopClient: _logger.warning("[new_task] 点击失败:%s", exc) return - # 等待 chat-root.message-count=0 或 welcome-screen 出现,最多 5s deadline = time.time() + 5 while time.time() < deadline: 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": - _logger.debug("[new_task] 已进入新任务(message-count=0)") - return - if page.locator('[data-jcid="welcome-screen"]').count() > 0: - _logger.debug("[new_task] 欢迎屏已出现") + _logger.debug("[new_task] 已进入新任务(data-message-count=0)") return except Exception: pass @@ -450,25 +477,35 @@ class JiangchangDesktopClient: # ─── 网关健康检查 ───────────────────────────────────────────── 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: - page = self.get_page() - el = page.locator('[data-jcid="gateway-status"]').first - if el.count() == 0: - return "unknown" - return el.get_attribute("data-state") or "unknown" + state = self.get_page().evaluate( + "() => window.__jc_gateway_state__" + ) + if isinstance(state, str) and state: + return state except Exception: - return "unknown" + pass + return 'unknown' 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() try: - page.wait_for_function( - """() => { - const el = document.querySelector('[data-jcid="gateway-status"]'); - return el && el.dataset.state === 'running'; - }""", - timeout=timeout_ms, - ) + page.wait_for_selector(SEL_CHAT_PAGE, timeout=timeout_ms) except Exception as exc: _logger.warning("[wait_gateway_ready] 超时/异常:%s(继续,但后续可能报 GatewayDown)", exc) @@ -481,7 +518,7 @@ class JiangchangDesktopClient: def _message_node_count(self) -> int: try: - return self.get_page().locator('[data-jcid="message"]').count() + return self.get_page().locator(SEL_MESSAGE_ROW).count() except Exception: return 0 @@ -489,10 +526,10 @@ class JiangchangDesktopClient: """返回 `[data-jcid="message"]` 节点列表中最后一条 role=user 的 index;找不到返回 -1。""" try: 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): - node = page.locator('[data-jcid="message"]').nth(i) - role = node.get_attribute("data-jcid-role") or "" + node = page.locator(SEL_MESSAGE_ROW).nth(i) + role = node.get_attribute("data-role") or "" if role.lower() == "user": return i return -1 @@ -507,7 +544,7 @@ class JiangchangDesktopClient: return False try: 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 "" t = re.sub(r"\s+", "", text) q = re.sub(r"\s+", "", question) @@ -529,22 +566,19 @@ class JiangchangDesktopClient: return None try: page = self.get_page() - nodes = page.locator('[data-jcid="message"]') + nodes = page.locator(SEL_MESSAGE_ROW) total = nodes.count() for i in range(total - 1, user_idx, -1): node = nodes.nth(i) - role = (node.get_attribute("data-jcid-role") or "").lower() + role = (node.get_attribute("data-role") or "").lower() if role != "assistant": continue - body = node.locator('[data-jcid="message-body"]') - has_body = body.count() > 0 - body_text = "" - if has_body: - try: - body_text = body.first.inner_text(timeout=2000) or "" - except Exception: - body_text = "" - streaming = (node.get_attribute("data-jcid-streaming") or "false").lower() == "true" + try: + body_text = node.inner_text(timeout=2000) or "" + except Exception: + body_text = "" + has_body = len(body_text.strip()) > 0 + streaming = (node.get_attribute("data-streaming") or "false").lower() == "true" return { "index": i, "has_body": has_body, @@ -575,8 +609,8 @@ class JiangchangDesktopClient: def _chat_root_sending(self) -> Optional[bool]: """chat-root[data-jcid-sending] 的 bool 化;读不到返回 None。""" try: - val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute( - "data-jcid-sending" + val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute( + "data-sending" ) if val is None: return None @@ -586,8 +620,8 @@ class JiangchangDesktopClient: def _chat_root_message_count(self) -> Optional[int]: try: - val = self.get_page().locator('[data-jcid="chat-root"]').first.get_attribute( - "data-jcid-message-count" + val = self.get_page().locator(SEL_CHAT_PAGE).first.get_attribute( + "data-message-count" ) if val is None: return None @@ -626,16 +660,16 @@ class JiangchangDesktopClient: """ 等待 agent 整轮(可能包含多次 thinking / tool_use 往返)完成。 - 核心判据 —— 只有 agent 最终答复才有 `message-body`(文本气泡): + 核心判据 —— agent 最终答复在 data-role=assistant 行上有非空 inner_text: 源码依据(D:\\AI\\jiangchang/src/stores/chat/helpers.ts): - isToolOnlyMessage 显式允许 thinking 与 tool_use 共存仍视为中间轮 - - 中间工具轮的 ChatMessage 无 hasText → 不渲染 MessageBubble → - 也就不会有 `data-jcid="message-body"` - - 只有最终含 text 的助手消息才会渲染 message-body + - 中间工具轮可能无可见正文气泡 → data-role 行 inner_text 为空 + - 只有最终含 text 的助手消息行 inner_text 非空(has_body) 因此可靠的完成条件为: - (A)最后一条 assistant message 存在 [data-jcid="message-body"]; - (B)其文本连续 `stable_seconds` 秒无变化; + (A)user_msg_index 之后最后一条 assistant 的 has_body 为 True + (由 _latest_assistant_after 根据 inner_text 判定); + (B)其 body_text 连续 `stable_seconds` 秒无变化; (C)同时 window.__jc_sending__ 不是 true(允许 None / False,以容忍 早期 React 未挂载注入钩子的情况)。 @@ -675,10 +709,10 @@ class JiangchangDesktopClient: assistant = self._latest_assistant_after(user_msg_index) # 完成判据需同时满足: - # (1) 三路 sending 信号全部静默(chat-root / window / 未收起的 execution graph) - # (2) user_msg_index 之后存在 assistant 消息 - # (3) 该 assistant 存在 message-body - # (4) 该 assistant 的 data-jcid-streaming != 'true' + # (1) 三路 sending 信号全部静默(chat-page data-sending / window / 未收起的 execution graph) + # (2) user_msg_index 之后存在 assistant 消息(data-role 行) + # (3) 该 assistant has_body 为 True(_latest_assistant_after,inner_text 非空) + # (4) 该 assistant 的 data-streaming != 'true' # (5) body 文本连续 stable_seconds 秒无变化 can_check_stable = ( not sending_any @@ -740,7 +774,7 @@ class JiangchangDesktopClient: raise JcTimeoutError( 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 ──────────────────────────── @@ -844,70 +878,56 @@ class JiangchangDesktopClient: 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) if picked is None or not picked["has_body"]: _logger.warning( - "[ask] user_idx=%d 之后未找到带 message-body 的 assistant(picked=%s)", + "[ask] user_idx=%d 之后未找到 has_body 的 assistant(picked=%s)", my_user_idx, picked, ) # 兜底:取我们 user 之后的任意最后一条 assistant inner_text try: page = self.get_page() - nodes = page.locator('[data-jcid="message"]') + nodes = page.locator(SEL_MESSAGE_ROW) total = nodes.count() for i in range(total - 1, my_user_idx, -1): 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 "" except Exception: pass return "" _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"]), ) return picked["body_text"] 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"]') - if attach_btn.count() == 0: - attach_btn = page.locator("button").filter( - has_text=re.compile(r"附件|上传|paperclip|attach", re.I) - ) + 匠厂 v2.0.17(ClawX v0.4.3 合并后)的附件上传通道由原生 Electron + showOpenDialog + IPC stage-paths 实现,不再暴露 HTML 。 - file_input = page.locator('input[type="file"]') - if file_input.count() == 0 and attach_btn.count() > 0: - attach_btn.first.click() - time.sleep(0.5) - file_input = page.locator('input[type="file"]') + Playwright 无法直接触发 Electron 原生对话框。需要匠厂主仓库新增: + - window.__jc_stage_paths__(paths: string[]) 调用桥,或 + - 一个隐藏 备用通道,带 data-testid - if file_input.count() == 0: - raise AssertError( - "找不到文件上传 input[type='file']。请确认匠厂已加 data-jcid=\"attach-button\"。" - ) + 在匠厂提供其中之一之前,本方法抛 NotImplementedError 而非静默成功。 - abs_path = os.path.abspath(file_path) - if not os.path.exists(abs_path): - raise AppNotFoundError(f"上传文件不存在: {abs_path}") - - file_input.set_input_files(abs_path) - time.sleep(1) - - if message: - input_locator = self._get_chat_input_locator() - input_locator.click() - input_locator.press_sequentially(message, delay=25) - input_locator.press("Enter") + Tracking: 见 docs/sdk-migration-discovery.md §7 + """ + raise NotImplementedError( + "send_file is temporarily unavailable on jiangchang v2.0.17+. " + "Reason: attachments now go through Electron IPC; SDK requires " + "a new DOM-or-window hook from the host application. " + "See docs/sdk-migration-discovery.md §7." + ) def read(self) -> List[JiangchangMessage]: """ - 严格读取:仅接受带 data-jcid="message" + data-jcid-role 的节点。 + 严格读取:仅接受带 data-role 语义锚点的 ChatMessage 行节点。 无 role 的节点返回 role='unknown'(不再默认为 assistant)。 """ if not self._connected: @@ -917,41 +937,35 @@ class JiangchangDesktopClient: messages: List[JiangchangMessage] = [] try: - msg_list = self._get_message_list_locator() - msg_list.wait_for(state="visible", timeout=5000) + page.locator(SEL_CHAT_PAGE).wait_for(state="visible", timeout=5000) except Exception as exc: - _logger.debug("[read] 消息列表不可见:%s", exc) + _logger.debug("[read] 聊天页不可见:%s", exc) return messages - items = page.locator('[data-jcid="message"]') + items = page.locator(SEL_MESSAGE_ROW) total = items.count() - _logger.debug("[read] data-jcid=\"message\" 节点数=%d", total) + _logger.debug("[read] data-role 消息行数=%d", total) for i in range(total): el = items.nth(i) try: - role_attr = el.get_attribute("data-jcid-role") - streaming_attr = el.get_attribute("data-jcid-streaming") or "false" - # 优先从 message-body(正文气泡)取干净文本,避免带入 Thinking / ToolCards - 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_attr = el.get_attribute("data-role") + streaming_attr = el.get_attribute("data-streaming") or "false" + content = el.inner_text(timeout=2000) role = (role_attr or "unknown").lower() + tool_status = "streaming" if streaming_attr == "true" else None messages.append( 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, content=content, timestamp=time.time(), - is_error=(el.get_attribute("data-jcid-error") == "true"), - tool_name=el.get_attribute("data-jcid-tool"), + is_error=False, + tool_name=None, + tool_status=tool_status, ) ) - if streaming_attr == "true": - messages[-1].tool_status = "streaming" except Exception as exc: _logger.debug("[read] 读取第%d条异常:%s", i, exc) continue