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:
parent
474597cfc6
commit
7dfdf4c73b
|
|
@ -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)。
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
64
web/app.py
64
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.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。"""
|
||||||
|
|
|
||||||
|
|
@ -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="按状态筛选">
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 先有选项)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue