diff --git a/PROGRESS.md b/PROGRESS.md index 1de58e6..2cb6cb2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,14 @@ ## 已完成关键能力 +### 2026-06-25 / 渠道镜像对话改成左栏固定卡片 + 企业微信也只读(bump 0.27.3) + +- 把微信 / 企业微信常驻对话从「任务列表里置顶 + 绿徽章 + 绿边的行」改成「『新建任务』下方两张固定卡片」(`#channel-cards`):它们是每用户每渠道唯一的常驻只读镜像,从可滚动任务列表抽出更清爽、常驻可见。 +- 后端:`/v1/tasks` 列表用 `func.coalesce(Task.channel,'web').notin_(CHANNEL_MIRROR_KINDS)` 排除渠道任务,并删掉原 `case(...)` 强制置顶;新增 `GET /v1/channel_tasks` 返回 `{wechat, wecom}` 两条摘要(复用 `_task_dict`,无则 null)。`CHANNEL_MIRROR_KINDS=("wechat","wecom")` 单一真相源。 +- 前端:`dev.html` 加 `#channel-cards` 块 + `.channel-card` 绿调样式(`:empty` 自动隐藏);`chat.js` 加 `loadChannelCards()`(enterApp/刷新按钮调)+ `syncChannelCardActive`(selectTask 同步高亮);移除列表行已失效的绿徽章逻辑。 +- 企业微信对话补只读锁:`applyChannelComposerLock` / `sendMessage` 守卫从硬编码 `channel==='wechat'` 改读 `CHANNEL_BADGE`(`channelCfg`),微信 + 企业微信都 readonly,提示文案按渠道动态。 +- 文件:`web/app.py`(列表排除 + 新端点 + 常量,移除 `case` import)、`web/static/dev.html`(卡片容器 + CSS)、`web/static/js/chat.js`(卡片渲染 + 只读锁统一)、`web/static/js/main.js`(enterApp 调 loadChannelCards)。 + ### 2026-06-25 / 企业微信入站对话支持图片/文件附件(bump 0.27.2) - 接续 0.27.0 企业微信入站(此前只收文本)。补图片/文件:`wecom.download_media(media_id)` 走 `media/get`(成功回二进制流 + Content-Disposition 文件名,出错回 JSON errcode、40014/42001 重取 token);回调按 `MsgType` 分支,image/file 下载后构造 `InboundAttachment(kind/file_name/data)`(与个人微信同结构,仅这三字段被用到)→ 喂同一 `_run_channel_conversation`,复用其落盘 + 拼 `[用户上传的...]` 行(图片 agent 自调 look_at_image,文件走 Read)。 diff --git a/core/__init__.py b/core/__init__.py index 9dee5fd..6476bc8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.27.2" +__version__ = "0.27.3" diff --git a/web/app.py b/web/app.py index 8b98045..7e20b44 100644 --- a/web/app.py +++ b/web/app.py @@ -32,7 +32,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from pydantic import BaseModel -from sqlalchemy import BigInteger, case, cast, func, select, update +from sqlalchemy import BigInteger, cast, func, select, update from starlette.background import BackgroundTask from core import __version__ @@ -65,6 +65,9 @@ from .static_files import NoCacheStaticFiles STATUS_FILTERS = ("active", "completed", "abandoned") STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) +# 渠道镜像 task 的 channel 取值(每用户每渠道一条常驻只读对话):从普通任务列表排除, +# 改由 /v1/channel_tasks 单独取、前端做成固定卡片。新增渠道在此追加即可。 +CHANNEL_MIRROR_KINDS = ("wechat", "wecom") ORDER_FIELDS = ("created_at", "updated_at", "name", "status") ORDER_DEFAULT = "-created_at" @@ -1676,7 +1679,13 @@ def create_app() -> FastAPI: } or None # 组装 WHERE(软删除的 task 永不出现在列表;恢复见 /restore) - conditions = [Task.user_id == user_id, Task.deleted_at.is_(None)] + # 渠道镜像 task(wechat/wecom 常驻对话)不进普通列表 —— 它们在左栏「新建任务」下 + # 做成固定卡片(GET /v1/channel_tasks),从列表排除避免重复。coalesce 兜 NULL(老 web task)。 + conditions = [ + Task.user_id == user_id, + Task.deleted_at.is_(None), + func.coalesce(Task.channel, "web").notin_(CHANNEL_MIRROR_KINDS), + ] if status: conditions.append(Task.status == status) if skill: @@ -1698,12 +1707,9 @@ def create_app() -> FastAPI: select(func.count()).select_from(Task).where(*conditions) ).scalar_one() or 0 - # 微信渠道常驻 task 后端强制置顶(恒排全局最上,跨分页稳定), - # 用户选的排序对其余 task 照常生效。 - pin = case((Task.channel == "wechat", 0), else_=1).asc() rows = s.execute( select(Task).where(*conditions) - .order_by(pin, *_parse_ordering(ordering)) + .order_by(*_parse_ordering(ordering)) .limit(page_size).offset(offset) ).scalars().all() @@ -1732,6 +1738,52 @@ def create_app() -> FastAPI: ], } + @app.get("/v1/channel_tasks", tags=["tasks"]) + def list_channel_tasks(user_id: UUID = Depends(require_user)): + """渠道镜像 task(微信 ClawBot / 企业微信)的常驻对话摘要 —— 前端在左栏「新建任务」 + 下做成固定卡片。每渠道每用户至多一条;未建过(没绑定 / 没说过话)→ 该键为 null。 + 返回 `{"wechat": |null, "wecom": |null}`,task_dict 与 /v1/tasks + 列表项同构(复用 _task_dict),前端可直接 selectTask。""" + from core.wechat import service as _wx + + snap = _wx.get_binding(user_id) + tids: dict[str, Optional[UUID]] = { + "wechat": snap.chat_task_id if snap else None, + "wecom": _wx.get_wecom_chat_task(user_id), + } + wanted = [t for t in tids.values() if t is not None] + out: dict[str, Optional[dict]] = {"wechat": None, "wecom": None} + if not wanted: + return out + with session_scope() as s: + rows = { + r.task_id: r + for r in s.execute( + select(Task).where( + Task.task_id.in_(wanted), + Task.user_id == user_id, + Task.deleted_at.is_(None), + ) + ).scalars().all() + } + msg_counts = dict( + s.execute( + select(Message.task_id, func.count()) + .where(Message.task_id.in_(list(rows.keys()))) + .group_by(Message.task_id) + ).all() + ) if rows else {} + usage = _usage_aggregates(s, list(rows.keys())) + for kind, tid in tids.items(): + row = rows.get(tid) if tid else None + if row is not None: + out[kind] = _task_dict( + row, + n_messages=msg_counts.get(row.task_id, 0), + usage=usage.get(row.task_id), + ) + return out + @app.get("/v1/tasks/{task_id}", tags=["tasks"]) def get_task(task_id: str, user_id: UUID = Depends(require_user)): """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。""" diff --git a/web/static/dev.html b/web/static/dev.html index 70a8298..41e4295 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -557,10 +557,19 @@ .badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center; gap: 3px; vertical-align: 1px; } .badge.wx svg { width: 11px; height: 11px; fill: currentColor; display: block; } - /* 整行标记:绿色左边框 + 极淡绿底,让置顶的微信任务从普通任务里跳出来 */ - .task-row.wx { border-left: 3px solid #07C160; padding-left: 9px; background: rgba(7,193,96,.05); } - .task-row.wx:hover { background: rgba(7,193,96,.10); } - .task-row.wx.active { background: var(--accent-soft); border-left-color: #07C160; } + /* 渠道镜像对话卡片(微信 / 企业微信):固定在「新建任务」下,绿调入口,与普通任务列表分离 */ + #channel-cards { padding: 6px 12px 2px; display: flex; flex-direction: column; gap: 6px; } + #channel-cards:empty { display: none; } + .channel-card { display: flex; align-items: center; gap: 7px; padding: 7px 9px; border-radius: 8px; + border: 1px solid rgba(7,193,96,.35); background: rgba(7,193,96,.06); cursor: pointer; } + .channel-card:hover { background: rgba(7,193,96,.12); } + .channel-card.active { border-color: #07C160; background: var(--accent-soft); } + .channel-card .cc-icon { width: 18px; height: 18px; border-radius: 5px; background: #07C160; color: #fff; + display: inline-flex; align-items: center; justify-content: center; flex: none; } + .channel-card .cc-icon svg { width: 12px; height: 12px; fill: currentColor; display: block; } + .channel-card .cc-name { font-weight: 600; font-size: 13px; flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .channel-card .cc-meta { color: var(--muted); font-size: 11px; flex: none; } .empty { padding: 24px; color: var(--muted); text-align: center; font-size: 13px; } /* ───── chat ───── */ @@ -1416,6 +1425,8 @@
+ +