mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 06:07:13 +08:00
feat: simplify Feishu card config and update 2.1.4 docs
This commit is contained in:
@@ -756,20 +756,15 @@ class FeiShuChanel(ChatChannel):
|
|||||||
content_key = "text"
|
content_key = "text"
|
||||||
prepared_content_json = None
|
prepared_content_json = None
|
||||||
if reply.type == ReplyType.TEXT:
|
if reply.type == ReplyType.TEXT:
|
||||||
delivery_text = reply.content
|
# Render Markdown text replies as Feishu cards; falls back to plain text automatically.
|
||||||
markdown_cards_enabled = conf().get("feishu_markdown_card", True)
|
delivery_text = resolve_markdown_images(
|
||||||
if markdown_cards_enabled:
|
reply.content,
|
||||||
delivery_text = resolve_markdown_images(
|
lambda url: upload_public_image_to_feishu(
|
||||||
delivery_text,
|
url,
|
||||||
lambda url: upload_public_image_to_feishu(
|
access_token,
|
||||||
url,
|
),
|
||||||
access_token,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
msg_type, prepared_content_json = build_text_delivery(
|
|
||||||
delivery_text,
|
|
||||||
enabled=markdown_cards_enabled,
|
|
||||||
)
|
)
|
||||||
|
msg_type, prepared_content_json = build_text_delivery(delivery_text)
|
||||||
elif reply.type == ReplyType.IMAGE_URL:
|
elif reply.type == ReplyType.IMAGE_URL:
|
||||||
# 图片上传
|
# 图片上传
|
||||||
reply_content = self._upload_image_url(reply.content, access_token)
|
reply_content = self._upload_image_url(reply.content, access_token)
|
||||||
@@ -897,13 +892,13 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
def _make_feishu_stream_callback(self, context, access_token):
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
"""Route to progress or plain streaming callback based on config.
|
"""Route to detailed or plain streaming callback based on config.
|
||||||
|
|
||||||
feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片
|
feishu_detailed_card 默认开启:普通对话使用带状态头、
|
||||||
(_make_feishu_stream_callback_plain)。开启后才使用带状态头、
|
思考/工具面板与耗时的详细卡片。关闭后回退到原有的打字机文本卡片
|
||||||
Reasoning/Tools 面板与耗时的富进度卡片。
|
(_make_feishu_stream_callback_plain)。
|
||||||
"""
|
"""
|
||||||
if conf().get("feishu_progress_card", False):
|
if conf().get("feishu_detailed_card", True):
|
||||||
return self._make_feishu_stream_callback_progress(context, access_token)
|
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||||
return self._make_feishu_stream_callback_plain(context, access_token)
|
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||||
|
|
||||||
@@ -1192,6 +1187,16 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if should_push:
|
if should_push:
|
||||||
push_queue.put(snapshot)
|
push_queue.put(snapshot)
|
||||||
|
|
||||||
|
elif event_type in ("tool_execution_start", "tool_execution_end"):
|
||||||
|
# Refresh the Tools panel as each tool starts/finishes so users
|
||||||
|
# see live status and per-tool elapsed time.
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
has_card = card_id[0] is not None
|
||||||
|
if has_card:
|
||||||
|
_drain_push_queue()
|
||||||
|
_update_full_card(streaming=True)
|
||||||
|
|
||||||
elif event_type == "message_end":
|
elif event_type == "message_end":
|
||||||
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||||
tool_calls = data.get("tool_calls", []) or []
|
tool_calls = data.get("tool_calls", []) or []
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from common import i18n
|
||||||
|
|
||||||
|
|
||||||
_MAX_PANEL_STEPS = 10
|
_MAX_PANEL_STEPS = 10
|
||||||
_MAX_STEP_CHARS = 800
|
_MAX_STEP_CHARS = 800
|
||||||
@@ -20,7 +22,8 @@ class FeishuProgressState:
|
|||||||
self.current_text = ""
|
self.current_text = ""
|
||||||
self._reasoning_buffer = ""
|
self._reasoning_buffer = ""
|
||||||
self.reasoning_steps: List[str] = []
|
self.reasoning_steps: List[str] = []
|
||||||
self.tool_steps: List[Dict[str, str]] = []
|
self.tool_steps: List[Dict[str, Any]] = []
|
||||||
|
self._tool_index: Dict[str, Dict[str, Any]] = {}
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
|
|
||||||
def consume(self, event: Dict[str, Any]) -> None:
|
def consume(self, event: Dict[str, Any]) -> None:
|
||||||
@@ -49,13 +52,33 @@ class FeishuProgressState:
|
|||||||
|
|
||||||
if event_type == "message_end":
|
if event_type == "message_end":
|
||||||
self._commit_reasoning()
|
self._commit_reasoning()
|
||||||
for tool_call in data.get("tool_calls") or []:
|
return
|
||||||
self.tool_steps.append(
|
|
||||||
{
|
if event_type == "tool_execution_start":
|
||||||
"summary": _format_tool_call(tool_call),
|
tool_id = data.get("tool_call_id")
|
||||||
"status": "running",
|
step = {
|
||||||
}
|
"summary": str(data.get("tool_name") or "tool"),
|
||||||
)
|
"status": "running",
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
"elapsed": None,
|
||||||
|
}
|
||||||
|
self.tool_steps.append(step)
|
||||||
|
if tool_id:
|
||||||
|
self._tool_index[tool_id] = step
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "tool_execution_end":
|
||||||
|
tool_id = data.get("tool_call_id")
|
||||||
|
step = self._tool_index.get(tool_id) if tool_id else None
|
||||||
|
if step is None:
|
||||||
|
# Fall back to the most recent running step when no id match.
|
||||||
|
step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None)
|
||||||
|
if step is not None:
|
||||||
|
step["status"] = "error" if data.get("status") not in (None, "success") else "done"
|
||||||
|
elapsed = data.get("execution_time")
|
||||||
|
if elapsed is None and step.get("started_at") is not None:
|
||||||
|
elapsed = time.monotonic() - step["started_at"]
|
||||||
|
step["elapsed"] = elapsed
|
||||||
return
|
return
|
||||||
|
|
||||||
if event_type == "agent_cancelled":
|
if event_type == "agent_cancelled":
|
||||||
@@ -78,35 +101,35 @@ class FeishuProgressState:
|
|||||||
|
|
||||||
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
||||||
"""Render the current state as a Feishu Card 2.0 object."""
|
"""Render the current state as a Feishu Card 2.0 object."""
|
||||||
|
# Localized status header text; en/zh/zh-Hant via i18n.t.
|
||||||
title, template = {
|
title, template = {
|
||||||
"running": ("Working", "blue"),
|
"running": (i18n.t("处理中", "Working"), "blue"),
|
||||||
"done": ("Done", "green"),
|
"done": (i18n.t("完成", "Done"), "green"),
|
||||||
"stopped": ("Stopped", "grey"),
|
"stopped": (i18n.t("已停止", "Stopped"), "grey"),
|
||||||
"error": ("Error", "red"),
|
"error": (i18n.t("出错", "Error"), "red"),
|
||||||
}.get(self.status, ("Working", "blue"))
|
}.get(self.status, (i18n.t("处理中", "Working"), "blue"))
|
||||||
|
|
||||||
main_text = self.current_text or "..."
|
main_text = self.current_text or "..."
|
||||||
elements: List[Dict[str, Any]] = []
|
elements: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Only render the Reasoning panel when there is real reasoning content.
|
||||||
|
# Upstream emits reasoning_update only when deep thinking is enabled, so
|
||||||
|
# an empty reasoning_steps means we should show no panel at all.
|
||||||
if self.reasoning_steps:
|
if self.reasoning_steps:
|
||||||
elements.append(
|
elements.append(
|
||||||
_panel(
|
_panel(
|
||||||
"Reasoning ({})".format(len(self.reasoning_steps)),
|
"🤔 {}".format(i18n.t("思考", "Thinking")),
|
||||||
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
||||||
expanded=streaming,
|
expanded=streaming,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif streaming:
|
|
||||||
elements.append(
|
|
||||||
_panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.tool_steps:
|
if self.tool_steps:
|
||||||
elements.append(
|
elements.append(
|
||||||
_panel(
|
_panel(
|
||||||
"Tools ({})".format(len(self.tool_steps)),
|
"🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)),
|
||||||
[
|
[
|
||||||
_text_row("{} · {}".format(step["summary"], step["status"]))
|
_text_row(_format_tool_step(step))
|
||||||
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
||||||
],
|
],
|
||||||
expanded=streaming,
|
expanded=streaming,
|
||||||
@@ -122,7 +145,7 @@ class FeishuProgressState:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
||||||
turn_label = "turn" if self.turns == 1 else "turns"
|
turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns")
|
||||||
elements.extend(
|
elements.extend(
|
||||||
[
|
[
|
||||||
{"tag": "hr"},
|
{"tag": "hr"},
|
||||||
@@ -147,15 +170,20 @@ class FeishuProgressState:
|
|||||||
"print_strategy": "fast",
|
"print_strategy": "fast",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
card: Dict[str, Any] = {
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
"config": config,
|
"config": config,
|
||||||
"header": {
|
|
||||||
"template": template,
|
|
||||||
"title": {"tag": "plain_text", "content": title},
|
|
||||||
},
|
|
||||||
"body": {"elements": elements},
|
"body": {"elements": elements},
|
||||||
}
|
}
|
||||||
|
# Hide the status header once the run has finished successfully; a plain
|
||||||
|
# answer needs no "Done" banner. Keep the header for running/stopped/error
|
||||||
|
# so users still get progress and failure signals.
|
||||||
|
if self.status != "done":
|
||||||
|
card["header"] = {
|
||||||
|
"template": template,
|
||||||
|
"title": {"tag": "plain_text", "content": title},
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
|
||||||
def _commit_reasoning(self) -> None:
|
def _commit_reasoning(self) -> None:
|
||||||
reasoning = self._reasoning_buffer.strip()
|
reasoning = self._reasoning_buffer.strip()
|
||||||
@@ -167,12 +195,25 @@ class FeishuProgressState:
|
|||||||
for step in self.tool_steps:
|
for step in self.tool_steps:
|
||||||
if step["status"] == "running":
|
if step["status"] == "running":
|
||||||
step["status"] = "done"
|
step["status"] = "done"
|
||||||
|
if step.get("elapsed") is None and step.get("started_at") is not None:
|
||||||
|
step["elapsed"] = time.monotonic() - step["started_at"]
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_call(tool_call: Dict[str, Any]) -> str:
|
def _tool_status_label(status: str) -> str:
|
||||||
# Tool arguments can contain user data or credentials. The progress card
|
if status == "running":
|
||||||
# only needs an activity label, so never echo raw arguments into chat.
|
return i18n.t("执行中", "running")
|
||||||
return str(tool_call.get("name") or "tool")
|
if status == "error":
|
||||||
|
return i18n.t("失败", "error")
|
||||||
|
return i18n.t("完成", "done")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tool_step(step: Dict[str, Any]) -> str:
|
||||||
|
# Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s".
|
||||||
|
parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])]
|
||||||
|
elapsed = step.get("elapsed")
|
||||||
|
if isinstance(elapsed, (int, float)):
|
||||||
|
parts.append("{:.1f}s".format(max(0.0, float(elapsed))))
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
||||||
@@ -180,7 +221,9 @@ def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[s
|
|||||||
"tag": "collapsible_panel",
|
"tag": "collapsible_panel",
|
||||||
"expanded": expanded,
|
"expanded": expanded,
|
||||||
"background_color": "grey",
|
"background_color": "grey",
|
||||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
# Panel title uses markdown so we can shrink the font via text_size
|
||||||
|
# (plain_text titles ignore text_size and break card rendering).
|
||||||
|
"header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}},
|
||||||
"border": {"color": "grey"},
|
"border": {"color": "grey"},
|
||||||
"vertical_spacing": "8px",
|
"vertical_spacing": "8px",
|
||||||
"padding": "4px 8px",
|
"padding": "4px 8px",
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ def build_markdown_card(text: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_text_delivery(text: str, enabled: bool = True) -> Tuple[str, str]:
|
def build_text_delivery(text: str) -> Tuple[str, str]:
|
||||||
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
||||||
if enabled and contains_markdown(text):
|
if contains_markdown(text):
|
||||||
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||||
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
"feishu_app_id": "",
|
"feishu_app_id": "",
|
||||||
"feishu_app_secret": "",
|
"feishu_app_secret": "",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true,
|
||||||
"feishu_markdown_card": true,
|
|
||||||
"feishu_progress_card": false,
|
|
||||||
"dingtalk_client_id": "",
|
"dingtalk_client_id": "",
|
||||||
"dingtalk_client_secret": "",
|
"dingtalk_client_secret": "",
|
||||||
"wecom_bot_id": "",
|
"wecom_bot_id": "",
|
||||||
@@ -44,7 +42,5 @@
|
|||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"knowledge": true,
|
"knowledge": true,
|
||||||
"self_evolution_enabled": true,
|
"self_evolution_enabled": true,
|
||||||
"mcp_tool_retrieval_enabled": false,
|
"mcp_tool_retrieval_enabled": false
|
||||||
"mcp_tool_retrieval_threshold": 20,
|
|
||||||
"mcp_tool_retrieval_top_k": 10
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,8 +178,7 @@ available_setting = {
|
|||||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||||
"feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure
|
"feishu_detailed_card": True, # render normal chat streaming as a detailed card (status header, thinking/tool panels, elapsed time); off keeps the plain typewriter card
|
||||||
"feishu_progress_card": False, # render normal chat streaming as a rich progress card (status header, reasoning/tool panels, elapsed time); off by default keeps the plain typewriter card
|
|
||||||
# DingTalk config
|
# DingTalk config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true
|
||||||
"feishu_markdown_card": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,8 +73,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | Feishu app App ID | - |
|
| `feishu_app_id` | Feishu app App ID | - |
|
||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
| `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` |
|
| `feishu_detailed_card` | Use a detailed card (tool calls, thinking process, elapsed time) for streaming replies; off keeps the plain typewriter card | `true` |
|
||||||
| `feishu_progress_card` | Use a rich progress card (status header, reasoning/tool panels, elapsed time) for normal chat streaming; off keeps the plain typewriter card | `false` |
|
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Quoted replies | ✅ quoted text and rich-post context |
|
| Quoted replies | ✅ quoted text and rich-post context |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
|
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
|
||||||
| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) |
|
| Detailed card | ✅ tool calls, thinking process and elapsed time (controlled by `feishu_detailed_card`, on by default) |
|
||||||
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true
|
||||||
"feishu_markdown_card": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,8 +70,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | Feishu アプリの App ID | - |
|
| `feishu_app_id` | Feishu アプリの App ID | - |
|
||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
|
| `feishu_detailed_card` | ストリーミング応答を詳細カード(ツール呼び出し、思考過程、経過時間)で表示。無効時は通常のタイプライターカード | `true` |
|
||||||
| `feishu_progress_card` | 通常会話のストリーミングをリッチ進捗カード(ステータス、推論/ツールパネル、経過時間)で表示。無効時は通常のタイプライターカード | `false` |
|
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -102,7 +100,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||||
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
| 詳細カード | ✅ ツール呼び出し、思考過程、経過時間(`feishu_detailed_card` で制御、デフォルト有効) |
|
||||||
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"feishu_app_secret": "YOUR_APP_SECRET",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true
|
||||||
"feishu_markdown_card": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,8 +74,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | 飞书应用 App ID | - |
|
| `feishu_app_id` | 飞书应用 App ID | - |
|
||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
|
| `feishu_detailed_card` | 流式回复是否使用详细卡片(工具调用、思考过程、耗时展示);关闭时用普通打字机卡片 | `true` |
|
||||||
| `feishu_progress_card` | 普通对话流式回复是否使用富进度卡片(状态头、思考/工具面板、耗时);关闭时用普通打字机卡片 | `false` |
|
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||||
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
| 详细卡片 | ✅ 工具调用、思考过程与耗时展示(`feishu_detailed_card` 控制,默认开启) |
|
||||||
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
---
|
---
|
||||||
title: v2.1.4
|
title: v2.1.4
|
||||||
description: CowAgent 2.1.4:桌面客户端浏览器与体验优化,MCP 远程服务支持 OAuth 授权,定时任务新增静默模式,并新增多款模型
|
description: CowAgent 2.1.4:桌面客户端体验优化,MCP 支持 OAuth 授权,定时任务工具优化,飞书通道能力增强,新增数据备份与多个模型
|
||||||
---
|
---
|
||||||
|
|
||||||
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
## 🖥 桌面客户端
|
## 🖥 桌面客户端
|
||||||
|
|
||||||
在 2.1.3 正式推出桌面客户端后,本次围绕浏览器能力、系统兼容与使用体验做了进一步打磨。
|
在上个版本正式推出桌面客户端后,本次围绕浏览器能力、系统兼容与使用体验做了进一步提升:
|
||||||
|
|
||||||
- **浏览器复用系统 Chrome/Edge**:内置浏览器工具优先复用系统已安装的 Chrome / Edge,同时为客户端内置 Playwright,无需额外手动安装浏览器即可使用网页访问、信息检索等能力。
|
- **支持浏览器工具**:客户端内置浏览器能力并优先复用系统已安装的 Chrome / Edge,并优化浏览器启动及访问性能。
|
||||||
- **浏览器访问提速**:优化页面导航流程,网页打开与内容抓取更快更稳。
|
|
||||||
- **新增 Win7 客户端**:新增对 Windows 7 等旧版系统的客户端支持,覆盖更多使用环境。
|
|
||||||
- **Windows 代码签名**:Windows 安装包新增代码签名,降低安装时的安全告警。
|
|
||||||
- **知识库文档跳转修复**:修复桌面端知识库文档内链接无法正常跳转的问题。
|
|
||||||
- **界面样式优化**:优化对话界面、消息气泡、工具调用步骤与通道页面的视觉与交互细节。
|
- **界面样式优化**:优化对话界面、消息气泡、工具调用步骤与通道页面的视觉与交互细节。
|
||||||
|
- **Windows 代码签名**:Windows 安装包新增代码签名,降低安装时的安全告警。
|
||||||
|
- **支持 Win7/Win8 系统**:新增对 Windows 7/8 等旧版系统的客户端支持,覆盖更多使用环境。
|
||||||
|
- **知识库文档跳转修复**:修复桌面端知识库文档内链接无法正常跳转的问题。
|
||||||
|
- **密码登录**:桌面客户端支持设置登录密码,并修复设置密码后无法加载窗口的问题。
|
||||||
|
|
||||||
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
|
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
|
||||||
|
|
||||||
@@ -22,18 +22,41 @@ description: CowAgent 2.1.4:桌面客户端浏览器与体验优化,MCP 远
|
|||||||
|
|
||||||
## 🔌 MCP 远程服务 OAuth 授权
|
## 🔌 MCP 远程服务 OAuth 授权
|
||||||
|
|
||||||
远程 MCP 服务新增 **OAuth 授权** 支持,接入需要登录授权的第三方 MCP 服务时,可通过标准 OAuth 流程完成认证,无需再手动配置和维护令牌。
|
远程 MCP 服务新增 **OAuth 授权** 支持,接入需要登录授权的第三方 MCP 服务时,可通过标准 OAuth 流程完成认证,无需手动配置和维护令牌。
|
||||||
|
|
||||||
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
||||||
|
|
||||||
## ⏰ 定时任务静默模式
|
## ⏰ 定时任务
|
||||||
|
|
||||||
定时任务新增 **静默模式**,任务在后台执行时不再主动推送中间过程,仅在需要时输出结果,避免打扰。同时修正了对消息投递类任务的误判,让任务标记更准确。Thanks @AaronZ345 (#2954)
|
- **静默模式**:支持创建静默执行的定时任务,任务后台运行、无主动消息推送,适合数据整理、定期归档等无需打扰的场景。(#2954)
|
||||||
|
- **手动执行**:支持在控制台界面手动触发已有任务立即执行,无需等待下次调度。(#2958)
|
||||||
|
- **编辑保留配置**:修复在 Web 端编辑任务时可能丢失模式类型等隐藏配置的问题。(#2959)
|
||||||
|
- **跨通道任务命令**:新增 `/tasks` 定时任务管理命令,多通道兼容。(#2965)
|
||||||
|
|
||||||
此外,修复了在 Web 界面编辑定时任务时可能丢失静默模式等隐藏配置的问题,编辑后任务设置得以完整保留。Thanks @AaronZ345 (#2959)
|
Thanks @AaronZ345
|
||||||
|
|
||||||
相关文档:[定时任务](https://docs.cowagent.ai/zh/tools/scheduler)
|
相关文档:[定时任务](https://docs.cowagent.ai/zh/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 飞书通道增强
|
||||||
|
|
||||||
|
飞书通道新增一系列消息展示与交互增强,提升使用体验。
|
||||||
|
|
||||||
|
- **流式卡片优化**:流式卡片增加折叠面板,展示思考过程、工具调用、执行耗时等信息。(#2963)
|
||||||
|
- **Markdown 格式**:对于非流式回复与定时推送,在含有 Markdown 语法时以卡片渲染,展示更清晰。(#2962)
|
||||||
|
- **定时任务卡片**:定时任务 `/tasks` 命令以卡片形式呈现,并支持在卡片中启用和关闭任务。(#2961)
|
||||||
|
- **支持消息引用**:用户引用消息时,自动将被引用消息加入上下文发送给 Agent。(#2966)
|
||||||
|
- **远程图片渲染**:支持在飞书卡片中渲染远程图片链接。(#2967)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
相关文档:[飞书](https://docs.cowagent.ai/zh/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 数据备份与恢复
|
||||||
|
|
||||||
|
新增 `cow backup` 和 `cow restore` 命令,支持一键导出与恢复配置、知识库、记忆等本地数据,便于迁移与备份。Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
相关文档:[数据备份](https://docs.cowagent.ai/zh/cli/backup)
|
||||||
|
|
||||||
## 🤖 模型新增
|
## 🤖 模型新增
|
||||||
|
|
||||||
- 新增支持 **kimi-k3**
|
- 新增支持 **kimi-k3**
|
||||||
@@ -43,12 +66,11 @@ description: CowAgent 2.1.4:桌面客户端浏览器与体验优化,MCP 远
|
|||||||
|
|
||||||
## 🛠 体验优化与修复
|
## 🛠 体验优化与修复
|
||||||
|
|
||||||
- **文件编辑更稳**:统一模糊匹配的唯一性校验逻辑,编辑文件时的定位更可靠。Thanks @weijun-xia (#2945)
|
- **文件编辑**:修复编辑文件时模糊匹配可能定位到错误位置的问题。Thanks @weijun-xia (#2945)
|
||||||
- **稳定性提升**:清理浏览器工具中的冗余逻辑分支,优化整体运行稳定性。
|
|
||||||
|
|
||||||
## 📦 升级方式
|
## 📦 升级方式
|
||||||
|
|
||||||
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
||||||
- **桌面客户端**:可在客户端内自动检查并一键更新,或前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
|
- **桌面客户端**:可在客户端内自动检查并一键更新,或前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
|
||||||
|
|
||||||
**发布日期**:2026.07.17 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
**发布日期**:2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
from channel.feishu.feishu_progress_card import FeishuProgressState
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
|
from common import i18n
|
||||||
|
|
||||||
|
# Card text is localized via i18n.t; lock English so assertions are stable
|
||||||
|
# regardless of the host machine locale.
|
||||||
|
i18n.set_language("en")
|
||||||
|
|
||||||
|
|
||||||
def _panels(card):
|
def _panels(card):
|
||||||
@@ -9,7 +14,7 @@ def _panels(card):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_running_card_has_status_reasoning_lane_and_stream_target():
|
def test_running_card_has_status_header_and_stream_target():
|
||||||
state = FeishuProgressState(started_at=100.0)
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
|
||||||
card = state.build_card(streaming=True, now=102.0)
|
card = state.build_card(streaming=True, now=102.0)
|
||||||
@@ -17,7 +22,8 @@ def test_running_card_has_status_reasoning_lane_and_stream_target():
|
|||||||
assert card["header"]["template"] == "blue"
|
assert card["header"]["template"] == "blue"
|
||||||
assert card["header"]["title"]["content"] == "Working"
|
assert card["header"]["title"]["content"] == "Working"
|
||||||
assert card["config"]["streaming_mode"] is True
|
assert card["config"]["streaming_mode"] is True
|
||||||
assert _panels(card)[0]["header"]["title"]["content"] == "Reasoning"
|
# No reasoning yet: the Reasoning panel must not be rendered.
|
||||||
|
assert _panels(card) == []
|
||||||
stream = next(
|
stream = next(
|
||||||
element
|
element
|
||||||
for element in card["body"]["elements"]
|
for element in card["body"]["elements"]
|
||||||
@@ -31,30 +37,33 @@ def test_tool_turn_populates_reasoning_and_tools_lanes():
|
|||||||
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
||||||
state.consume({"type": "reasoning_update", "data": {"delta": "Need the latest files."}})
|
state.consume({"type": "reasoning_update", "data": {"delta": "Need the latest files."}})
|
||||||
state.consume({"type": "message_update", "data": {"delta": "Checking now."}})
|
state.consume({"type": "message_update", "data": {"delta": "Checking now."}})
|
||||||
|
state.consume({"type": "message_end", "data": {"tool_calls": [{"name": "read_file"}]}})
|
||||||
state.consume(
|
state.consume(
|
||||||
{
|
{
|
||||||
"type": "message_end",
|
"type": "tool_execution_start",
|
||||||
"data": {
|
"data": {"tool_call_id": "t1", "tool_name": "read_file"},
|
||||||
"tool_calls": [
|
|
||||||
{"name": "read_file", "arguments": {"path": "README.md"}}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
card = state.build_card(streaming=True, now=103.0)
|
card = state.build_card(streaming=True, now=103.0)
|
||||||
panels = _panels(card)
|
panels = _panels(card)
|
||||||
assert [panel["header"]["title"]["content"] for panel in panels] == [
|
assert panels[0]["header"]["title"]["content"] == "🤔 Thinking"
|
||||||
"Reasoning (1)",
|
assert panels[1]["header"]["title"]["content"] == "🔧 Tools (1)"
|
||||||
"Tools (1)",
|
|
||||||
]
|
|
||||||
assert panels[0]["elements"][0]["text"]["content"] == "Need the latest files."
|
assert panels[0]["elements"][0]["text"]["content"] == "Need the latest files."
|
||||||
assert "read_file" in panels[1]["elements"][0]["text"]["content"]
|
assert "read_file" in panels[1]["elements"][0]["text"]["content"]
|
||||||
assert "running" in panels[1]["elements"][0]["text"]["content"]
|
assert "running" in panels[1]["elements"][0]["text"]["content"]
|
||||||
|
|
||||||
state.consume({"type": "turn_start", "data": {"turn": 2}})
|
# Tool end marks the step done and shows its own elapsed time.
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "tool_execution_end",
|
||||||
|
"data": {"tool_call_id": "t1", "status": "success", "execution_time": 1.2},
|
||||||
|
}
|
||||||
|
)
|
||||||
done_card = state.build_card(streaming=True, now=104.0)
|
done_card = state.build_card(streaming=True, now=104.0)
|
||||||
assert "done" in _panels(done_card)[1]["elements"][0]["text"]["content"]
|
tool_row = _panels(done_card)[1]["elements"][0]["text"]["content"]
|
||||||
|
assert "done" in tool_row
|
||||||
|
assert "1.2s" in tool_row
|
||||||
|
|
||||||
|
|
||||||
def test_agent_end_finalizes_status_body_and_footer():
|
def test_agent_end_finalizes_status_body_and_footer():
|
||||||
@@ -69,8 +78,8 @@ def test_agent_end_finalizes_status_body_and_footer():
|
|||||||
|
|
||||||
card = state.build_card(streaming=False, now=105.2)
|
card = state.build_card(streaming=False, now=105.2)
|
||||||
|
|
||||||
assert card["header"]["template"] == "green"
|
# Successful completion hides the status header entirely.
|
||||||
assert card["header"]["title"]["content"] == "Done"
|
assert "header" not in card
|
||||||
assert card["config"]["streaming_mode"] is False
|
assert card["config"]["streaming_mode"] is False
|
||||||
assert next(
|
assert next(
|
||||||
element["content"]
|
element["content"]
|
||||||
@@ -101,3 +110,30 @@ def test_cancelled_agent_uses_partial_output_and_stopped_status():
|
|||||||
for element in card["body"]["elements"]
|
for element in card["body"]["elements"]
|
||||||
if element.get("element_id") == "stream_md"
|
if element.get("element_id") == "stream_md"
|
||||||
) == "partial"
|
) == "partial"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_text_is_localized_in_chinese():
|
||||||
|
try:
|
||||||
|
i18n.set_language("zh")
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
||||||
|
state.consume({"type": "reasoning_update", "data": {"delta": "思考"}})
|
||||||
|
state.consume({"type": "message_end", "data": {"tool_calls": [{"name": "read_file"}]}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "tool_execution_start",
|
||||||
|
"data": {"tool_call_id": "t1", "tool_name": "read_file"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=True, now=102.0)
|
||||||
|
|
||||||
|
assert card["header"]["title"]["content"] == "处理中"
|
||||||
|
panels = _panels(card)
|
||||||
|
assert panels[0]["header"]["title"]["content"] == "🤔 思考"
|
||||||
|
assert panels[1]["header"]["title"]["content"] == "🔧 工具 (1)"
|
||||||
|
assert "执行中" in panels[1]["elements"][0]["text"]["content"]
|
||||||
|
footer = card["body"]["elements"][-1]
|
||||||
|
assert footer["content"] == "2.0s · 1 轮"
|
||||||
|
finally:
|
||||||
|
i18n.set_language("en")
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ def test_markdown_reply_uses_card_2_markdown_element():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_cards_can_be_disabled():
|
|
||||||
msg_type, content = build_text_delivery("# Status", enabled=False)
|
|
||||||
|
|
||||||
assert msg_type == "text"
|
|
||||||
assert json.loads(content) == {"text": "# Status"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_detection_avoids_common_plain_text_punctuation():
|
def test_markdown_detection_avoids_common_plain_text_punctuation():
|
||||||
assert contains_markdown("release 1.2.0 - all checks passed") is False
|
assert contains_markdown("release 1.2.0 - all checks passed") is False
|
||||||
assert contains_markdown("Use `cow --help` for details") is True
|
assert contains_markdown("Use `cow --help` for details") is True
|
||||||
|
|||||||
Reference in New Issue
Block a user