feat(wechat): 微信对话 task 渠道标记 + 列表置顶(channel 字段)+ bump 0.23.0

tasks 加 channel 列(web/wechat,migration 0013,server_default='web' 回填存量,
并把 description='(微信 ClawBot 对话)' 的存量 task backfill 成 wechat)。微信常驻
task 后端强制置顶(列表查询前置 case pin 表达式,跨分页稳定),前端任务名前打绿色
「微信」徽章一眼可辨。channel 仅 INSERT 写定,后续 upsert/save 不传不覆盖。

- core/storage/models.py: Task.channel 列
- db/migrations/.../0013_task_channel.py: 加列 + backfill
- core/storage/utils.py: ensure_local_task_row 加 channel 参数
- web/app.py: 微信建 task 传 channel=wechat;_task_dict 透出;列表 pin 置顶
- web/static/js/chat.js: channel===wechat 打徽章

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-24 11:12:16 +08:00
parent 95857ba687
commit 85336ccb7e
8 changed files with 69 additions and 5 deletions

View File

@ -334,6 +334,8 @@ users(user_id uuid pk, email text null unique, password_hash text null, oidc_sub
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd, status, model_profile, tokens_prompt, tokens_completion, cost_usd,
channel text not null default 'web', -- web/wechat 渠道来源(0013);仅 INSERT 写定,
-- upsert/save 不传不覆盖。前端据此打徽章 + 列表强制置顶
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表) run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表)
run_error text null, run_error text null,
created_at, updated_at); created_at, updated_at);

View File

@ -21,6 +21,13 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-24 / 微信对话 task 渠道标记 + 置顶(bump 0.23.0)
- 痛点:微信常驻 task 与网页常规 task 结构相同,只能靠 description 魔法值反推;且 `created_at` 固定后随用户开新 task 越沉越深,这个「渠道收件箱」反而最难找。
- `tasks``channel` 列(`web`/`wechat`,migration 0013,`server_default='web'` 回填存量、并把 description=`(微信 ClawBot 对话)` 的存量 task backfill 成 `wechat`)。`ensure_local_task_row` 加 `channel` 参数,微信建 task 处传 `wechat`;`channel` 仅 INSERT 写定,后续 upsert/save 不传 → 不覆盖。
- `_task_dict` 透出 `channel`;列表查询排序前置 `case((channel=='wechat',0),else_=1)` pin 表达式 → 微信 task 后端强制置顶(跨分页稳定),用户选的排序对其余 task 照常生效。
- 前端 `chat.js` 任务名前打绿色「微信」徽章(`channel==='wechat'`)。文件:`core/storage/models.py`、`core/storage/utils.py`、`web/app.py`、`web/static/js/chat.js`、`db/migrations/versions/...0013_task_channel.py`。
### 2026-06-24 / 微信绑定 UI 并入主 SPA(bump 0.22.2) ### 2026-06-24 / 微信绑定 UI 并入主 SPA(bump 0.22.2)
- 上一版绑定页是独立 `/static/wechat_bind.html`,主界面没入口、用户找不到。 - 上一版绑定页是独立 `/static/wechat_bind.html`,主界面没入口、用户找不到。

View File

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

View File

@ -65,6 +65,9 @@ class Task(Base):
working_dir: Mapped[str] = mapped_column(Text, nullable=False) working_dir: Mapped[str] = mapped_column(Text, nullable=False)
skill: Mapped[str] = mapped_column(Text, nullable=False, default="") skill: Mapped[str] = mapped_column(Text, nullable=False, default="")
description: Mapped[str] = mapped_column(Text, nullable=False, default="") description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 渠道来源(0011):web=网页端常规任务 / wechat=微信 ClawBot 常驻对话。
# 仅 INSERT 时由建 task 方写定,后续 upsert/save 不传 → 不覆盖。前端据此打徽章 + 置顶。
channel: Mapped[str] = mapped_column(Text, nullable=False, default="web", server_default="web")
status: Mapped[str] = mapped_column(Text, nullable=False, default="active") status: Mapped[str] = mapped_column(Text, nullable=False, default="active")
model: Mapped[str] = mapped_column(Text, nullable=False, default="") model: Mapped[str] = mapped_column(Text, nullable=False, default="")
model_profile: Mapped[str] = mapped_column(Text, nullable=False, default="") model_profile: Mapped[str] = mapped_column(Text, nullable=False, default="")

View File

@ -25,6 +25,7 @@ def ensure_local_task_row(
model: str = "", model: str = "",
model_profile: str = "", model_profile: str = "",
reasoning_effort: str = "", reasoning_effort: str = "",
channel: str = "web",
) -> None: ) -> None:
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。 """占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
@ -45,6 +46,7 @@ def ensure_local_task_row(
model=model, model=model,
model_profile=model_profile, model_profile=model_profile,
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
channel=channel,
) )
.on_conflict_do_nothing(index_elements=["task_id"]) .on_conflict_do_nothing(index_elements=["task_id"])
) )

View File

@ -0,0 +1,42 @@
"""tasks.channel 列(渠道来源:web / wechat).
Revision ID: 0013
Revises: 0012
Create Date: 2026-06-24
tasks channel ,标记任务来源渠道:
- web = 网页端常规任务(默认)
- wechat = 微信 ClawBot 常驻对话(每用户一条)
只加列不动现有数据;server_default='web' 让历史行自动回填为 web建表后把
现网已存在的微信常驻 task(description = '(微信 ClawBot 对话)')backfill
'wechat',让置顶 / 徽章逻辑对存量数据立即生效
前端据 channel 给微信任务打徽章并后端强制置顶(列表查询排序前置 pin 表达式)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0013"
down_revision: Union[str, None] = "0012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("channel", sa.Text(), nullable=False, server_default="web"),
)
# backfill 存量微信常驻 task —— 用建 task 时写死的 description 作标记。
op.execute(
"UPDATE tasks SET channel = 'wechat' "
"WHERE description = '(微信 ClawBot 对话)'"
)
def downgrade() -> None:
op.drop_column("tasks", "channel")

View File

@ -32,7 +32,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
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, cast, func, select, update from sqlalchemy import BigInteger, case, cast, func, select, update
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from core import __version__ from core import __version__
@ -190,6 +190,7 @@ def _task_dict(
"working_dir": _norm_path(row.working_dir or ""), "working_dir": _norm_path(row.working_dir or ""),
"status": row.status, "status": row.status,
"skill": row.skill or "", "skill": row.skill or "",
"channel": getattr(row, "channel", None) or "web",
"model": row.model or "", "model": row.model or "",
"model_profile": row.model_profile or "", "model_profile": row.model_profile or "",
"tokens_prompt": tokens_prompt, "tokens_prompt": tokens_prompt,
@ -883,7 +884,7 @@ def create_app() -> FastAPI:
ensure_local_task_row( ensure_local_task_row(
task_id=tid, name="微信对话", working_dir=to_db_path(fs_dir), task_id=tid, name="微信对话", working_dir=to_db_path(fs_dir),
skill="", user_id=uid, model=model_id, model_profile=profile, skill="", user_id=uid, model=model_id, model_profile=profile,
description="(微信 ClawBot 对话)", description="(微信 ClawBot 对话)", channel="wechat",
) )
await asyncio.to_thread(_wx.set_chat_task, uid, tid) await asyncio.to_thread(_wx.set_chat_task, uid, tid)
@ -1440,9 +1441,12 @@ 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(*_parse_ordering(ordering)) .order_by(pin, *_parse_ordering(ordering))
.limit(page_size).offset(offset) .limit(page_size).offset(offset)
).scalars().all() ).scalars().all()

View File

@ -124,10 +124,14 @@ 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)
// 微信渠道任务:置顶 + 名前打绿色「微信」徽章,一眼可辨(后端已强制排最上)
const wechatTag = t.channel === "wechat"
? `<span class="badge active" title="微信 ClawBot 渠道" style="margin-right:4px;">微信</span>`
: "";
return ` return `
<div class="task-row${active}" 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">${escapeHtml(taskName)}</div> <div class="desc">${wechatTag}${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">