feat(web): 微信/企业微信对话改成左栏固定卡片 + 企业微信也只读 + bump 0.27.3

把渠道镜像对话(每用户每渠道唯一的常驻只读对话)从「任务列表置顶行 +
绿徽章 + 绿边」改成「新建任务下方两张固定卡片」,与可滚动任务列表分离、
常驻可见;顺带补企业微信对话的 web 端只读锁。

- 后端 /v1/tasks 用 coalesce(channel,'web').notin_(CHANNEL_MIRROR_KINDS)
  排除渠道任务并删掉 case() 强制置顶;新增 GET /v1/channel_tasks 返回
  {wechat, wecom} 摘要(复用 _task_dict,无则 null)
- 前端加 #channel-cards 卡片块(:empty 自动隐藏)+ loadChannelCards/
  syncChannelCardActive;移除列表行已失效的绿徽章逻辑
- applyChannelComposerLock / sendMessage 守卫从硬编码 channel==='wechat'
  改读 CHANNEL_BADGE,微信 + 企业微信都 readonly,提示文案按渠道动态

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-25 12:32:12 +08:00
parent 474597cfc6
commit 7dfdf4c73b
6 changed files with 155 additions and 35 deletions

View File

@ -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) ### 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)。 - 接续 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)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.27.2" __version__ = "0.27.3"

View File

@ -32,7 +32,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from pydantic import BaseModel 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 starlette.background import BackgroundTask
from core import __version__ from core import __version__
@ -65,6 +65,9 @@ from .static_files import NoCacheStaticFiles
STATUS_FILTERS = ("active", "completed", "abandoned") STATUS_FILTERS = ("active", "completed", "abandoned")
STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) 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_FIELDS = ("created_at", "updated_at", "name", "status")
ORDER_DEFAULT = "-created_at" ORDER_DEFAULT = "-created_at"
@ -1676,7 +1679,13 @@ def create_app() -> FastAPI:
} or None } or None
# 组装 WHERE(软删除的 task 永不出现在列表;恢复见 /restore) # 组装 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: if status:
conditions.append(Task.status == status) conditions.append(Task.status == status)
if skill: if skill:
@ -1698,12 +1707,9 @@ def create_app() -> FastAPI:
select(func.count()).select_from(Task).where(*conditions) select(func.count()).select_from(Task).where(*conditions)
).scalar_one() or 0 ).scalar_one() or 0
# 微信渠道常驻 task 后端强制置顶(恒排全局最上,跨分页稳定),
# 用户选的排序对其余 task 照常生效。
pin = case((Task.channel == "wechat", 0), else_=1).asc()
rows = s.execute( rows = s.execute(
select(Task).where(*conditions) select(Task).where(*conditions)
.order_by(pin, *_parse_ordering(ordering)) .order_by(*_parse_ordering(ordering))
.limit(page_size).offset(offset) .limit(page_size).offset(offset)
).scalars().all() ).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": <task_dict>|null, "wecom": <task_dict>|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"]) @app.get("/v1/tasks/{task_id}", tags=["tasks"])
def get_task(task_id: str, user_id: UUID = Depends(require_user)): def get_task(task_id: str, user_id: UUID = Depends(require_user)):
"""单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。""" """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。"""

View File

@ -557,10 +557,19 @@
.badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center; .badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center;
gap: 3px; vertical-align: 1px; } gap: 3px; vertical-align: 1px; }
.badge.wx svg { width: 11px; height: 11px; fill: currentColor; display: block; } .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); } #channel-cards { padding: 6px 12px 2px; display: flex; flex-direction: column; gap: 6px; }
.task-row.wx:hover { background: rgba(7,193,96,.10); } #channel-cards:empty { display: none; }
.task-row.wx.active { background: var(--accent-soft); border-left-color: #07C160; } .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; } .empty { padding: 24px; color: var(--muted); text-align: center; font-size: 13px; }
/* ───── chat ───── */ /* ───── chat ───── */
@ -1416,6 +1425,8 @@
<div class="pane-head"> <div class="pane-head">
<button id="hd-new" class="primary" style="flex:1;">+ 新建任务</button> <button id="hd-new" class="primary" style="flex:1;">+ 新建任务</button>
</div> </div>
<!-- 渠道镜像对话(微信 / 企业微信)固定卡片:由 chat.js loadChannelCards 填充;无则 :empty 隐藏 -->
<div id="channel-cards"></div>
<div class="pane-head task-filter-row" style="gap: 6px;"> <div class="pane-head task-filter-row" style="gap: 6px;">
<input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:2; padding: 3px 6px;" /> <input id="filter-q" class="small" placeholder="搜索名称/描述..." style="flex:2; padding: 3px 6px;" />
<select id="filter-status" class="small" style="flex:1; width:auto;" title="按状态筛选"> <select id="filter-status" class="small" style="flex:1; width:auto;" title="按状态筛选">

View File

@ -13,6 +13,14 @@ import { mqPhone, setMobileView } from "./layout.js";
// 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白) // 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白)
const WECHAT_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.5-6.446 1.45-1.595 3.711-2.55 6.286-2.55.165 0 .33.01.495.027C18.486 4.916 13.929 2.188 8.691 2.188zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.121 2.361-.343a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088a8.067 8.067 0 0 0-.346-.034zm-2.71 3.711c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>`; const WECHAT_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.5-6.446 1.45-1.595 3.711-2.55 6.286-2.55.165 0 .33.01.495.027C18.486 4.916 13.929 2.188 8.691 2.188zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.121 2.361-.343a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088a8.067 8.067 0 0 0-.346-.034zm-2.71 3.711c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>`;
// 渠道镜像 task(后端 _run_channel_conversation 建):'wechat'=个人微信 ClawBot,'wecom'=企业微信。
// 两者在 web 端都是只读镜像(唯一交互入口在对应 App,web 发的消息推不回去)—— 列表打绿徽章 +
// 绿边、对话框 readonly。单一真相源:徽章文案 / 锁定提示 / 发送拦截全读这张表。logo 复用微信系图标。
const CHANNEL_BADGE = {
wechat: { label: "微信", title: "微信 ClawBot 渠道" },
wecom: { label: "企业微信", title: "企业微信渠道" },
};
function channelCfg(ch) { return CHANNEL_BADGE[ch] || null; }
import { logout } from "./auth.js"; import { logout } from "./auth.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js"; import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js"; import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
@ -127,16 +135,11 @@ function renderTaskList(tasks, append = false) {
const desc = t.description || ""; const desc = t.description || "";
const statusLabel = statusLabels[t.status] || t.status; const statusLabel = statusLabels[t.status] || t.status;
const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8) const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
// 微信渠道任务:置顶 + 名前打微信绿徽章(含 logo)+ 整行绿边,一眼可辨(后端已强制排最上) // 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
const isWechat = t.channel === "wechat";
const wechatTag = isWechat
? `<span class="badge wx" title="微信 ClawBot 渠道" style="margin-right:4px;">${WECHAT_ICON}微信</span>`
: "";
const wxRow = isWechat ? " wx" : "";
return ` return `
<div class="task-row${active}${wxRow}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;"> <div class="task-row${active}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="desc">${wechatTag}${escapeHtml(taskName)}</div> <div class="desc">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""} ${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""} ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
<div class="meta"> <div class="meta">
@ -180,6 +183,49 @@ function renderTaskList(tasks, append = false) {
}); });
} }
// 渠道镜像对话卡片(微信 / 企业微信):左栏「新建任务」下方固定入口。这两条是每用户每渠道
// 唯一的常驻只读对话,不混进可滚动任务列表(后端 /v1/tasks 已排除)。无对话(没绑定 / 没说过话)
// → 该渠道无卡片;两个都无 → 容器空,CSS :empty 隐藏整块。点卡片复用 selectTask。
export async function loadChannelCards() {
const box = $("channel-cards");
if (!box) return;
let data;
try {
data = await api("GET", "/v1/channel_tasks");
} catch (e) {
if (e.status === 401) { logout(); return; }
box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏(列表仍可用)
return;
}
// CHANNEL_BADGE 的键序(wechat 先、wecom 后)决定卡片顺序,与徽章/锁定文案同一真相源
const cards = Object.keys(CHANNEL_BADGE)
.map((kind) => ({ kind, t: data && data[kind] }))
.filter((x) => x.t && x.t.task_id);
if (!cards.length) { box.innerHTML = ""; return; }
box.innerHTML = cards.map(({ kind, t }) => {
const cfg = CHANNEL_BADGE[kind];
const active = state.taskId === t.task_id ? " active" : "";
const name = t.name || cfg.label + "对话";
const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))}`;
return `
<div class="channel-card${active}" data-tid="${t.task_id}" title="${escapeHtml(cfg.title)}">
<span class="cc-icon">${WECHAT_ICON}</span>
<span class="cc-name">${escapeHtml(name)}</span>
<span class="cc-meta">${meta}</span>
</div>`;
}).join("");
box.querySelectorAll(".channel-card").forEach((el) => {
el.onclick = () => selectTask(el.dataset.tid);
});
}
// selectTask 切换时同步卡片高亮(卡片 task 不在 .task-row 列表里,需单独刷 active 态)
function syncChannelCardActive(tid) {
document.querySelectorAll("#channel-cards .channel-card").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
}
function taskMenuItems(t) { function taskMenuItems(t) {
const isActive = t.status === "active"; const isActive = t.status === "active";
const hasMsg = (t.n_messages || 0) > 0; const hasMsg = (t.n_messages || 0) > 0;
@ -203,7 +249,7 @@ function taskMenuItems(t) {
$("filter-status").onchange = () => loadTaskList(); $("filter-status").onchange = () => loadTaskList();
$("filter-order").onchange = () => loadTaskList(); $("filter-order").onchange = () => loadTaskList();
$("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛 $("filter-wd").onchange = () => loadTaskList(); // select 选完立即筛
$("btn-refresh-tasks").onclick = () => loadTaskList(); $("btn-refresh-tasks").onclick = () => { loadTaskList(); loadChannelCards(); };
// 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。 // 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。
function applyTaskFiltersCollapsed(collapsed) { function applyTaskFiltersCollapsed(collapsed) {
@ -244,6 +290,7 @@ export async function selectTask(tid) {
document.querySelectorAll(".task-row").forEach((el) => { document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid); el.classList.toggle("active", el.dataset.tid === tid);
}); });
syncChannelCardActive(tid); // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op) // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
if (mqPhone.matches) setMobileView("mv-mid"); if (mqPhone.matches) setMobileView("mv-mid");
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await // 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
@ -973,21 +1020,21 @@ async function deletePastedFile(rel, wrap) {
} }
} }
// 微信渠道 task 在 web 端只读:这条对话的唯一交互入口锚定在微信本身 —— agent 回复必须 // 渠道镜像 task(微信 / 企业微信)在 web 端只读:这条对话的唯一交互入口锚定在对应 App ——
// 带 context_token 才发回微信,token 只能从用户微信入站消息拿(24h 过期),zcbot 在 // 微信侧 agent 回复必须带 context_token 才发回,token 只能从用户入站消息拿(24h 过期),
// 协议层就没有对微信无条件说话的能力。故 web→微信单向不同步(web 发的微信侧看不到); // 协议层没有无条件说话的能力;企业微信虽可主动推,但同样把交互权威收敛在 App 端,避免
// 与其做受 24h 窗口拖累的"有时同步"双向打通,不如让 web 端做干净的只读镜像(单一交互 // web/手机两路输入打架。故 web→渠道单向不同步,web 端做干净的只读镜像(单一交互权威 +
// 权威 + 可预测),想主动往微信推走 wechat_push / 定时简报。微信→web 仍同步(同一条 task)。 // 可预测),想主动推走 wechat_push / 定时简报。渠道→web 仍同步(同一条 task)。
function applyChannelComposerLock(meta) { function applyChannelComposerLock(meta) {
const input = $("chat-input"); const input = $("chat-input");
if (!input) return; if (!input) return;
const isWechat = !!meta && meta.channel === "wechat"; const cfg = meta && channelCfg(meta.channel); // 微信 / 企业微信镜像 → 只读
input.readOnly = isWechat; input.readOnly = !!cfg;
input.classList.toggle("readonly-locked", isWechat); input.classList.toggle("readonly-locked", !!cfg);
input.placeholder = isWechat input.placeholder = cfg
? "微信对话请在微信里进行 — web 端为只读镜像,可查看历史" ? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"; : "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
if (isWechat) { if (cfg) {
const opt = $("chat-optimize"); const opt = $("chat-optimize");
if (opt) opt.disabled = true; if (opt) opt.disabled = true;
} }
@ -1114,9 +1161,10 @@ function takePastedRels() {
async function sendMessage(overrideText) { async function sendMessage(overrideText) {
if (!state.taskId) return; if (!state.taskId) return;
if (isCurrentTaskStreaming()) return; if (isCurrentTaskStreaming()) return;
// 微信渠道 task 只读:web 发的消息推不到微信(单向),挡在入口避免不一致(见 applyChannelComposerLock) // 渠道镜像 task(微信 / 企业微信)只读:web 发的消息推不回去(单向),挡在入口避免不一致(见 applyChannelComposerLock)
if (state.taskMeta && state.taskMeta.channel === "wechat") { const _chCfg = state.taskMeta && channelCfg(state.taskMeta.channel);
$("chat-hint").textContent = "微信对话请在微信里进行 — web 端为只读镜像"; if (_chCfg) {
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
return; return;
} }
const fromInput = typeof overrideText !== "string"; const fromInput = typeof overrideText !== "string";

View File

@ -14,7 +14,7 @@ import { closeFilePreview, closeMiniPreview } from "./preview.js";
import { closeSrcPicker, loadFiles } from "./files.js"; import { closeSrcPicker, loadFiles } from "./files.js";
import { loadFolderSuggestions } from "./newtask.js"; import { loadFolderSuggestions } from "./newtask.js";
import { embedInit } from "./embed.js"; import { embedInit } from "./embed.js";
import { loadTaskList, loadModels } from "./chat.js"; import { loadTaskList, loadModels, loadChannelCards } from "./chat.js";
// ───── enter app ───── // ───── enter app ─────
export function enterApp() { export function enterApp() {
@ -22,6 +22,7 @@ export function enterApp() {
$("app").classList.add("ready"); $("app").classList.add("ready");
renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份 renderWho(); // 顶栏用户:默认显 name(兜底 user_name/email/uid8),hover 显完整身份
loadTaskList(); loadTaskList();
loadChannelCards(); // 渠道镜像对话(微信 / 企业微信)固定卡片,与列表并行拉
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项)