mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
fix: keep team chat history in sync and clean up speaker labels
In a group chat every Agent cached its own message list and only restored it on first init, so a teammate missed later turns spoken by someone else (and the host missed guest replies). Reload the shared transcript before each turn in a team conversation. Replay a colleague's past reply as a `Name(@id):` user turn instead of an `[Name]` assistant prefix, so the model has nothing to copy into its own reply, and strip any speaker label it still echoes. Tell it in the prompt not to prefix its reply or expose teammate ids to the user. Also refresh team.mdx with real screenshots. Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
1 parent
09893c1e9f
commit
f15d40bc7f
5 files changed
+263
-57
No files matched your search
+15
-12
@@ -951,17 +951,19 @@ def _build_team_section(runtime_info: Dict[str, Any], language: str) -> List[str
|
||||
"",
|
||||
*[f"- {line}" for line in roster],
|
||||
"",
|
||||
"Everyone here reads the same history. A reply that starts with "
|
||||
"someone's name in brackets was written by them; an unmarked one "
|
||||
"is your own. Do not take a teammate's work, or their promises, "
|
||||
"for yours.",
|
||||
"Everyone here reads the same history. A teammate's earlier "
|
||||
"reply is shown as `Name(@id):` before their words; an unmarked "
|
||||
"one is your own. Do not take a teammate's work, or their "
|
||||
"promises, for yours.",
|
||||
"",
|
||||
"This turn is yours to answer. Answer as yourself. When the user "
|
||||
"wants a teammate's own words, let them address that teammate.",
|
||||
"This turn is yours to answer. Answer as yourself, and do NOT "
|
||||
"prefix your reply with your own name or id — that label is only "
|
||||
"how others' past turns are shown to you.",
|
||||
"",
|
||||
"Use agent_delegate for work that belongs to a teammate, passing "
|
||||
"their id above as agent_id (without the @), and say who you handed "
|
||||
"it to and what you asked for. Refer to teammates by name to the user.",
|
||||
"it to and what you asked for. Refer to teammates by name to the "
|
||||
"user, without the @id — the id is internal.",
|
||||
"",
|
||||
]
|
||||
return [
|
||||
@@ -971,14 +973,15 @@ def _build_team_section(runtime_info: Dict[str, Any], language: str) -> List[str
|
||||
"",
|
||||
*[f"- {line}" for line in roster],
|
||||
"",
|
||||
"大家读到的是同一份记录。以方括号加名字开头的回复出自那位同事,"
|
||||
"没有标注的才是你自己说的。不要把同事做过的事、许下的承诺当成你的。",
|
||||
"大家读到的是同一份记录。以「名字(@id):」开头的是那位同事说过的话,"
|
||||
"没有前缀的才是你自己说的。不要把同事做过的事、许下的承诺当成你的。",
|
||||
"",
|
||||
"这一轮由你来回答,以你自己的身份回答即可。用户想听某位同事亲口说,"
|
||||
"让他去点那位同事。",
|
||||
"这一轮由你来回答,以你自己的身份回答即可,回答时不要在开头加自己的名字或 id "
|
||||
"前缀(前缀只是用来向你标记其他成员的历史发言)。",
|
||||
"",
|
||||
"该由某位同事做的事,用 agent_delegate 交出去:把那位同事上面的 id "
|
||||
"作为 agent_id 传入 (不含@符号),并说明交给了谁、交办了什么。对用户提到同事时用名字。",
|
||||
"作为 agent_id 传入 (不含@符号),并说明交给了谁、交办了什么。对用户提到同事时只用名字,"
|
||||
"不要带 @id,id 只用于内部。",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
@@ -789,6 +789,67 @@ class AgentBridge:
|
||||
for message in messages or []
|
||||
]
|
||||
|
||||
def _roster_speaker_labels(self) -> list:
|
||||
"""Names and ids a model might copy from a shared transcript."""
|
||||
labels = []
|
||||
try:
|
||||
for profile in self.agent_registry.list(include_disabled=True):
|
||||
if profile.name:
|
||||
labels.append(profile.name)
|
||||
if profile.id:
|
||||
labels.append(profile.id)
|
||||
except Exception:
|
||||
return []
|
||||
return labels
|
||||
|
||||
@staticmethod
|
||||
def _strip_speaker_prefix(text: str, labels: list) -> str:
|
||||
"""Drop a leading speaker label the model copied from history.
|
||||
|
||||
Covers the forms a shared transcript can show: ``[Name]``,
|
||||
``Name:`` and ``Name(@id):`` (with either colon).
|
||||
"""
|
||||
if not text or not labels:
|
||||
return text
|
||||
escaped = "|".join(
|
||||
re.escape(label)
|
||||
for label in sorted({label for label in labels if label}, key=len, reverse=True)
|
||||
)
|
||||
if not escaped:
|
||||
return text
|
||||
name = rf"(?:{escaped})"
|
||||
return re.sub(
|
||||
rf"^\s*(?:\[{name}\]|{name}\s*(?:\(@{name}\))?\s*[::])\s*",
|
||||
"",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def _strip_speaker_prefix_from_messages(self, messages: list) -> list:
|
||||
labels = self._roster_speaker_labels()
|
||||
if not labels:
|
||||
return messages
|
||||
cleaned = []
|
||||
for message in messages or []:
|
||||
if message.get("role") != "assistant":
|
||||
cleaned.append(message)
|
||||
continue
|
||||
content = message.get("content")
|
||||
if isinstance(content, list) and content and content[0].get("type") == "text":
|
||||
text = self._strip_speaker_prefix(content[0].get("text", ""), labels)
|
||||
cleaned.append({
|
||||
**message,
|
||||
"content": [{**content[0], "text": text}, *content[1:]],
|
||||
})
|
||||
elif isinstance(content, str):
|
||||
cleaned.append({
|
||||
**message,
|
||||
"content": self._strip_speaker_prefix(content, labels),
|
||||
})
|
||||
else:
|
||||
cleaned.append(message)
|
||||
return cleaned
|
||||
|
||||
def _begin_run(self, session_id: str, agent_id: str, context: Context = None):
|
||||
"""Open a run for this turn and make its id the ambient one.
|
||||
|
||||
@@ -944,6 +1005,27 @@ class AgentBridge:
|
||||
)
|
||||
return agent
|
||||
|
||||
def _sync_shared_transcript(self, agent, session_id: str, host_agent_id: str) -> None:
|
||||
"""Reload the host's transcript so every teammate sees the same history.
|
||||
|
||||
Solo conversations keep their live in-memory list (including tool
|
||||
chains). A team conversation is reread from the host store, with
|
||||
colleagues' replies replayed as ``Name:`` user turns so ``assistant``
|
||||
stays this speaker's own voice.
|
||||
"""
|
||||
if not session_id or not AgentInitializer._is_shared_conversation(
|
||||
session_id, host_agent_id
|
||||
):
|
||||
return
|
||||
restore = getattr(self.initializer, "_restore_conversation_history", None)
|
||||
if restore is None:
|
||||
return
|
||||
try:
|
||||
host = self.agent_registry.get(host_agent_id, require_enabled=False)
|
||||
except Exception:
|
||||
return
|
||||
restore(agent, session_id, host.workspace, host_agent_id)
|
||||
|
||||
def _apply_session_project(self, agent, session_id: str, agent_id: str) -> None:
|
||||
"""Retarget the agent's working directory to the session's project dir.
|
||||
|
||||
@@ -1186,6 +1268,13 @@ class AgentBridge:
|
||||
)
|
||||
if not agent:
|
||||
return Reply(ReplyType.ERROR, "Failed to initialize super agent")
|
||||
|
||||
# A team conversation is one transcript. Each Agent caches its own
|
||||
# in-memory list and only restores it on first init, so a teammate
|
||||
# that already joined would miss later turns spoken by someone else
|
||||
# (and the host would miss guest replies). Reload the shared
|
||||
# transcript with author labels before this turn is appended.
|
||||
self._sync_shared_transcript(agent, session_id, resolved_agent_id)
|
||||
|
||||
# Create event handler for logging and channel communication
|
||||
event_handler = AgentEventHandler(context=context, original_callback=on_event)
|
||||
@@ -1326,6 +1415,7 @@ class AgentBridge:
|
||||
new_messages = self._attribute_to_speaker(
|
||||
new_messages, speaker_agent_id
|
||||
)
|
||||
new_messages = self._strip_speaker_prefix_from_messages(new_messages)
|
||||
if new_messages:
|
||||
self._persist_messages(
|
||||
session_id,
|
||||
@@ -1361,6 +1451,11 @@ class AgentBridge:
|
||||
# changes take effect on the user's next message.
|
||||
self._schedule_mcp_hot_reload(agent)
|
||||
|
||||
if isinstance(response, str):
|
||||
response = self._strip_speaker_prefix(
|
||||
response, self._roster_speaker_labels()
|
||||
)
|
||||
|
||||
# Check if there are files to send (from send/read tool)
|
||||
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
|
||||
files_to_send = agent.stream_executor.files_to_send
|
||||
|
||||
+25
-15
@@ -282,36 +282,46 @@ class AgentInitializer:
|
||||
def _attribute_history(messages: list, reader_agent_id: str) -> list:
|
||||
"""Name the author of replies this Agent did not write.
|
||||
|
||||
Without this a shared transcript reads as a monologue: every earlier
|
||||
reply arrives in the same ``assistant`` role, so an Agent takes a
|
||||
colleague's work — and its promises — for its own. Its own turns are
|
||||
left bare, so "unlabelled" reads as "mine"; the team prompt says so.
|
||||
A shared transcript replays every earlier reply in the same
|
||||
``assistant`` role. Prefixing that role with ``[Name]`` teaches the
|
||||
model to open its own answer the same way. Replay a colleague's turn
|
||||
as a user message instead (``Name(@id):…``): ``assistant`` stays
|
||||
"mine", so the model has nothing to copy into the reply.
|
||||
"""
|
||||
from agent.registry import get_agent_registry
|
||||
|
||||
registry = get_agent_registry()
|
||||
names: Dict[str, str] = {}
|
||||
prefixes: Dict[str, str] = {}
|
||||
|
||||
def name_of(agent_id: str) -> str:
|
||||
if agent_id not in names:
|
||||
def prefix_of(agent_id: str) -> str:
|
||||
if agent_id not in prefixes:
|
||||
try:
|
||||
names[agent_id] = registry.get(agent_id, require_enabled=False).name
|
||||
name = registry.get(agent_id, require_enabled=False).name
|
||||
except Exception:
|
||||
names[agent_id] = agent_id
|
||||
return names[agent_id]
|
||||
name = agent_id
|
||||
prefixes[agent_id] = f"{name}(@{agent_id}):"
|
||||
return prefixes[agent_id]
|
||||
|
||||
attributed = []
|
||||
for message in messages:
|
||||
author = message.get("agent_id") or ""
|
||||
plain = {"role": message["role"], "content": message["content"]}
|
||||
if author and author != reader_agent_id and plain["role"] == "assistant":
|
||||
prefix = prefix_of(author)
|
||||
blocks = plain["content"]
|
||||
if isinstance(blocks, list) and blocks and blocks[0].get("type") == "text":
|
||||
label = f"[{name_of(author)}] "
|
||||
plain["content"] = [
|
||||
{**blocks[0], "text": label + blocks[0].get("text", "")},
|
||||
*blocks[1:],
|
||||
]
|
||||
text = blocks[0].get("text", "")
|
||||
if not text.startswith(prefix):
|
||||
text = prefix + text
|
||||
plain = {
|
||||
"role": "user",
|
||||
"content": [{**blocks[0], "text": text}, *blocks[1:]],
|
||||
}
|
||||
elif isinstance(blocks, str):
|
||||
plain = {
|
||||
"role": "user",
|
||||
"content": blocks if blocks.startswith(prefix) else prefix + blocks,
|
||||
}
|
||||
attributed.append(plain)
|
||||
return attributed
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ description: 将多个 Agent 组建成团队,让它们各司其职,在同一
|
||||
|
||||
CowAgent 不再只是一个 Agent,你可以创建多个 Agent 组成一支团队:每个成员有自己的职责、模型、技能和知识,既能各自独立工作,也能被拉进同一个会话里一起协作。
|
||||
|
||||
<Frame>
|
||||
{/* 截图占位:Web 控制台 / 桌面端「智能体团队」页面全景,左侧成员列表 + 右侧详情 */}
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/cow-team-overview.png" style={{ maxWidth: "800px" }} />
|
||||
</Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/e0750df7b766ee63dfb341e30e181859.png" alt="Multi-agent team" width="800"/>
|
||||
|
||||
## 为什么需要团队
|
||||
|
||||
@@ -31,10 +28,8 @@ CowAgent 不再只是一个 Agent,你可以创建多个 Agent 组成一支团
|
||||
- **技能与知识**:可以与团队共享,也可以让 Agent 拥有一套专属的知识库,互不影响。
|
||||
- **从已有 Agent 复制**:新建时可以复制一个现有 Agent 的配置、技能与知识作为起点。
|
||||
|
||||
<Frame>
|
||||
{/* 截图占位:创建 Agent 的表单,名称、头像、职责、模型、知识库模式 */}
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/cow-team-create.png" style={{ maxWidth: "600px" }} />
|
||||
</Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260908192724.png" alt="Agent-create" width="800"/>
|
||||
|
||||
|
||||
创建后,Agent 会出现在团队列表中。点击任意成员,右侧可以在「概况 / 技能 / 核心文件」之间切换,分别配置它的基本信息、启用的技能,以及它的人设与规则文件(`AGENT.md`、`RULE.md` 等)。
|
||||
|
||||
@@ -61,12 +56,10 @@ CowAgent 不再只是一个 Agent,你可以创建多个 Agent 组成一支团
|
||||
- **添加成员**:将其他 Agent 加入当前会话,转为群聊。
|
||||
- **移除成员**:将成员移出当前会话。
|
||||
|
||||
<Frame>
|
||||
{/* 截图占位:对话框左下角的 Agent 选择器,展示当前会话成员 + 可添加的成员 */}
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/cow-team-composer.png" style={{ maxWidth: "600px" }} />
|
||||
</Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260908194946.png" alt="Agent-team-group" width="800"/>
|
||||
|
||||
群聊里,每个 Agent 用自己配置的模型作答,气泡上会显示是哪个 Agent 在发言。
|
||||
|
||||
群聊里,每个 Agent 用自己配置的模型作答,气泡上会显示是哪个 Agent 在发言。同一会话里的成员读到的是同一份记录,能看到其他人说过什么。
|
||||
|
||||
<Note>
|
||||
群聊不使用会话级的模型选择,每个 Agent 都用自己配置的默认模型,因此群聊里不会出现模型选择器。
|
||||
@@ -81,10 +74,10 @@ CowAgent 不再只是一个 Agent,你可以创建多个 Agent 组成一支团
|
||||
- 第一个选中的是负责人(owner),负责接收这个通道的所有任务。
|
||||
- 可以再添加若干成员,负责人可以把某轮对话交给他们处理。
|
||||
|
||||
<Frame>
|
||||
{/* 截图占位:通道页面的「绑定智能体」下拉,owner + 成员 */}
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/cow-team-channel-bind.png" style={{ maxWidth: "600px" }} />
|
||||
</Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260908204924.png" alt="Agent-team-channel" width="800"/>
|
||||
|
||||
|
||||
|
||||
|
||||
绑定的改动会即时生效,无需重启通道。支持多实例的通道包括微信、企业微信机器人、钉钉、飞书、QQ、Telegram、Slack、Discord 等。
|
||||
|
||||
@@ -96,10 +89,8 @@ CowAgent 不再只是一个 Agent,你可以创建多个 Agent 组成一支团
|
||||
|
||||
在群聊里,用 `@` 加上成员的名字,就能把这一轮交给指定的 Agent 回答。输入 `@` 时会弹出当前会话里的成员供你选择,发送后气泡里也会带上被指定 Agent 的头像,标明这轮由谁负责。
|
||||
|
||||
<Frame>
|
||||
{/* 截图占位:群聊中 @ 某个成员,以及气泡里显示的 @成员 头像 */}
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/cow-team-mention.png" style={{ maxWidth: "600px" }} />
|
||||
</Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/3bfb130278053d66bffc26c881a78436.png" alt="Agent-team-group" width="800"/>
|
||||
|
||||
|
||||
### 2. 主 Agent 与委派
|
||||
|
||||
|
||||
@@ -402,27 +402,35 @@ class TestKnowingWhoWroteWhat:
|
||||
]
|
||||
|
||||
def _attributed(self, tmp_path, reader):
|
||||
_bridge(tmp_path) # registry has to be resolvable for the name lookup
|
||||
from agent.registry import set_agent_registry
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
|
||||
bridge = _bridge(tmp_path)
|
||||
set_agent_registry(bridge.agent_registry)
|
||||
return AgentInitializer._attribute_history(self.HISTORY, reader)
|
||||
|
||||
def test_a_colleagues_reply_is_named(self, tmp_path):
|
||||
text = self._attributed(tmp_path, "default")[1]["content"][0]["text"]
|
||||
assert text.startswith("[")
|
||||
assert "shipped" in text
|
||||
def test_a_colleagues_reply_is_replayed_as_the_user(self, tmp_path):
|
||||
"""assistant+[Name] is what the model copies into its own mouth."""
|
||||
message = self._attributed(tmp_path, "default")[1]
|
||||
assert message["role"] == "user"
|
||||
assert message["content"][0]["text"].startswith("运营助手(@ops):")
|
||||
assert "shipped" in message["content"][0]["text"]
|
||||
|
||||
def test_your_own_reply_is_left_bare(self, tmp_path):
|
||||
"""Unmarked has to mean "mine", or the convention says nothing."""
|
||||
assert self._attributed(tmp_path, "default")[3]["content"][0]["text"] == "on it"
|
||||
def test_your_own_reply_stays_assistant(self, tmp_path):
|
||||
message = self._attributed(tmp_path, "default")[3]
|
||||
assert message["role"] == "assistant"
|
||||
assert message["content"][0]["text"] == "on it"
|
||||
|
||||
def test_the_same_reply_is_bare_for_the_one_who_wrote_it(self, tmp_path):
|
||||
assert self._attributed(tmp_path, "ops")[1]["content"][0]["text"] == "shipped"
|
||||
message = self._attributed(tmp_path, "ops")[1]
|
||||
assert message["role"] == "assistant"
|
||||
assert message["content"][0]["text"] == "shipped"
|
||||
|
||||
def test_the_users_own_turns_are_never_labelled(self, tmp_path):
|
||||
attributed = self._attributed(tmp_path, "default")
|
||||
assert [m["content"][0]["text"] for m in attributed if m["role"] == "user"] == [
|
||||
"ship it",
|
||||
"运营助手(@ops):shipped",
|
||||
"and now?",
|
||||
]
|
||||
|
||||
@@ -577,3 +585,102 @@ class TestMentionParsing:
|
||||
]
|
||||
assert self._resolve("@运营助手 你好", roster) == "b"
|
||||
assert self._resolve("@运营 你好", roster) == "a"
|
||||
|
||||
|
||||
class TestSharedTranscriptStaysCurrent:
|
||||
"""History is restored once on init. Without a reload, a teammate that
|
||||
already joined misses later turns spoken by someone else."""
|
||||
|
||||
def test_a_later_host_turn_is_visible_to_the_guest(self, tmp_path, monkeypatch):
|
||||
from agent.memory import clear_conversation_store_cache, get_conversation_store
|
||||
from agent.registry import set_agent_registry
|
||||
from agent.workspace import session_prefs
|
||||
from config import conf
|
||||
|
||||
bridge = _bridge(tmp_path)
|
||||
set_agent_registry(bridge.agent_registry)
|
||||
monkeypatch.setitem(conf(), "conversation_persistence", True)
|
||||
monkeypatch.setattr(
|
||||
session_prefs,
|
||||
"get_prefs",
|
||||
lambda sid, aid: {"members": ["ops"]} if sid == "chat" else {},
|
||||
)
|
||||
clear_conversation_store_cache()
|
||||
|
||||
store = get_conversation_store(str(tmp_path / "primary"))
|
||||
store.append_messages(
|
||||
"chat",
|
||||
[
|
||||
{"role": "user", "content": [{"type": "text", "text": "team roster"}]},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "ops is here"}],
|
||||
"extras": {"agent_id": "primary"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
guest = SimpleNamespace(
|
||||
agent_id="ops",
|
||||
workspace_dir=str(tmp_path / "ops"),
|
||||
messages=[{"role": "assistant", "content": [{"type": "text", "text": "stale"}]}],
|
||||
messages_lock=threading.RLock(),
|
||||
)
|
||||
initializer = AgentInitializer(bridge=None, agent_bridge=bridge)
|
||||
bridge.initializer = initializer
|
||||
initializer._restore_conversation_history(
|
||||
guest, "chat", str(tmp_path / "primary"), "primary"
|
||||
)
|
||||
assert any("ops is here" in str(m.get("content")) for m in guest.messages)
|
||||
|
||||
store.append_messages(
|
||||
"chat",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "CowAgent repo is here"}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "located the repo"}],
|
||||
"extras": {"agent_id": "primary"},
|
||||
},
|
||||
],
|
||||
)
|
||||
# The cached guest still has only what it restored the first time.
|
||||
assert not any("located the repo" in str(m.get("content")) for m in guest.messages)
|
||||
|
||||
bridge._sync_shared_transcript(guest, "chat", "primary")
|
||||
texts = [m["content"][0]["text"] for m in guest.messages]
|
||||
assert any(
|
||||
t.startswith("Primary(@primary):") and "located the repo" in t for t in texts
|
||||
)
|
||||
|
||||
def test_solo_conversation_does_not_reload(self, tmp_path, monkeypatch):
|
||||
from agent.workspace import session_prefs
|
||||
|
||||
monkeypatch.setattr(session_prefs, "get_prefs", lambda sid, aid: {})
|
||||
bridge = _bridge(tmp_path)
|
||||
guest = SimpleNamespace(
|
||||
agent_id="ops",
|
||||
messages=[{"keep": True}],
|
||||
messages_lock=threading.RLock(),
|
||||
)
|
||||
bridge._sync_shared_transcript(guest, "chat", "primary")
|
||||
assert guest.messages == [{"keep": True}]
|
||||
|
||||
|
||||
class TestStripCopiedSpeakerPrefix:
|
||||
def test_bracket_and_colon_prefixes_are_removed(self, tmp_path):
|
||||
bridge = _bridge(tmp_path)
|
||||
labels = ["团队负责人", "开发", "default", "developer"]
|
||||
assert (
|
||||
bridge._strip_speaker_prefix("[团队负责人] 浓缩一版", labels)
|
||||
== "浓缩一版"
|
||||
)
|
||||
assert bridge._strip_speaker_prefix("开发:仓库在这", labels) == "仓库在这"
|
||||
assert (
|
||||
bridge._strip_speaker_prefix("团队负责人(@default):浓缩一版", labels)
|
||||
== "浓缩一版"
|
||||
)
|
||||
assert bridge._strip_speaker_prefix("正常回复", labels) == "正常回复"
|
||||
Reference in new issue
Block a user