Skip to content

Commit 28322be

Browse files
committed
fix(ohmo): normalize chat channel output
1 parent 257916d commit 28322be

8 files changed

Lines changed: 605 additions & 39 deletions

File tree

ohmo/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,15 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
338338
"Send tool hints to channels?",
339339
default=existing.send_tool_hints,
340340
)
341+
remote_output_mode = _select_from_menu(
342+
"Remote channel output mode:",
343+
[
344+
("user", "User-facing output (hide raw tool details)"),
345+
("debug", "Debug output (show raw tool details)"),
346+
("silent", "Silent progress (final/media only)"),
347+
],
348+
default_value=existing.remote_output_mode,
349+
)
341350
allow_remote_admin_commands = _confirm_prompt(
342351
"Allow explicitly listed administrative slash commands from remote channels?",
343352
default=existing.allow_remote_admin_commands,
@@ -361,6 +370,7 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
361370
"channel_configs": channel_configs,
362371
"send_progress": send_progress,
363372
"send_tool_hints": send_tool_hints,
373+
"remote_output_mode": remote_output_mode,
364374
"allow_remote_admin_commands": allow_remote_admin_commands,
365375
"allowed_remote_admin_commands": allowed_remote_admin_commands,
366376
}

ohmo/gateway/bridge.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from openharness.channels.bus.queue import MessageBus
1313

1414
from ohmo.group_registry import load_managed_group_record
15+
from ohmo.gateway.output_normalizer import GatewayOutputState, normalize_gateway_update
1516
from ohmo.gateway.router import session_key_for_message
1617
from ohmo.gateway.runtime import OhmoSessionRuntimePool
1718

@@ -259,31 +260,43 @@ async def _process_message(self, message, session_key: str) -> None:
259260
reply = ""
260261
final_media: list[str] = []
261262
final_metadata: dict[str, object] = {}
263+
output_state = GatewayOutputState()
264+
output_mode = str(getattr(self._runtime_pool, "remote_output_mode", "user"))
262265
async for update in self._runtime_pool.stream_message(message, session_key):
263-
if update.kind == "final":
264-
reply = update.text
265-
final_media = list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or [])
266-
final_metadata = dict(update.metadata or {})
267-
continue
268-
if not update.text:
269-
continue
270-
logger.info(
271-
"ohmo outbound update channel=%s chat_id=%s session_key=%s kind=%s content=%r",
272-
message.channel,
273-
message.chat_id,
274-
session_key,
275-
update.kind,
276-
_content_snippet(update.text),
277-
)
278-
await self._bus.publish_outbound(
279-
OutboundMessage(
280-
channel=message.channel,
281-
chat_id=message.chat_id,
282-
content=update.text,
283-
media=list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or []),
284-
metadata={**inbound_meta, **(update.metadata or {})},
266+
for outgoing in normalize_gateway_update(
267+
update,
268+
channel=message.channel,
269+
content=message.content,
270+
mode=output_mode,
271+
state=output_state,
272+
):
273+
outgoing_media = list(
274+
getattr(outgoing, "media", None) or (outgoing.metadata or {}).get("_media") or []
275+
)
276+
if outgoing.kind == "final":
277+
reply = outgoing.text
278+
final_media = outgoing_media
279+
final_metadata = dict(outgoing.metadata or {})
280+
continue
281+
if not outgoing.text and not outgoing_media:
282+
continue
283+
logger.info(
284+
"ohmo outbound update channel=%s chat_id=%s session_key=%s kind=%s content=%r",
285+
message.channel,
286+
message.chat_id,
287+
session_key,
288+
outgoing.kind,
289+
_content_snippet(outgoing.text),
290+
)
291+
await self._bus.publish_outbound(
292+
OutboundMessage(
293+
channel=message.channel,
294+
chat_id=message.chat_id,
295+
content=outgoing.text,
296+
media=outgoing_media,
297+
metadata={**inbound_meta, **(outgoing.metadata or {})},
298+
)
285299
)
286-
)
287300
except asyncio.CancelledError:
288301
logger.info(
289302
"ohmo session interrupted channel=%s chat_id=%s session_key=%s reason=%s",

ohmo/gateway/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class GatewayConfig(BaseModel):
1313
session_routing: str = "chat-thread"
1414
send_progress: bool = True
1515
send_tool_hints: bool = True
16+
remote_output_mode: str = "user"
1617
permission_mode: str = "default"
1718
sandbox_enabled: bool = False
1819
allow_remote_admin_commands: bool = False

ohmo/gateway/output_normalizer.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""User-facing output normalization for ohmo gateway channels."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from pathlib import Path
7+
import re
8+
9+
from ohmo.gateway.runtime import GatewayStreamUpdate
10+
11+
REMOTE_CHANNELS = {
12+
"discord",
13+
"dingtalk",
14+
"email",
15+
"feishu",
16+
"matrix",
17+
"qq",
18+
"slack",
19+
"telegram",
20+
"wechat",
21+
"whatsapp",
22+
}
23+
24+
_LOCAL_PATH_RE = re.compile(
25+
r"(?P<path>(?:(?<![A-Za-z0-9])[A-Za-z]:[\\/]|(?<![:/])/)(?:[^\r\n`\"'<>|?*\x00])+)",
26+
)
27+
_PATH_CODE_BLOCK_RE = re.compile(
28+
r"```(?:text|txt|powershell|shell|bash)?\s*\n(?P<body>.*?)\n```",
29+
re.IGNORECASE | re.DOTALL,
30+
)
31+
_URL_RE = re.compile(r"https?://[^\s`<>\"']+")
32+
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
33+
34+
35+
@dataclass
36+
class GatewayOutputState:
37+
"""Per-message state for channel-facing output normalization."""
38+
39+
stage_keys: set[str] = field(default_factory=set)
40+
media_paths: set[str] = field(default_factory=set)
41+
has_image_media: bool = False
42+
saw_media: bool = False
43+
44+
45+
def normalize_gateway_update(
46+
update: GatewayStreamUpdate,
47+
*,
48+
channel: str,
49+
content: str,
50+
mode: str,
51+
state: GatewayOutputState,
52+
) -> list[GatewayStreamUpdate]:
53+
"""Return channel-facing updates for one runtime update."""
54+
normalized_mode = _normalize_mode(mode)
55+
if normalized_mode == "debug" or channel not in REMOTE_CHANNELS:
56+
_remember_update_media(state, update)
57+
return [update]
58+
if update.kind == "media":
59+
_remember_update_media(state, update)
60+
return [_strip_media_caption(update)]
61+
if update.kind == "final":
62+
_remember_update_media(state, update)
63+
final_text = normalize_final_reply(update.text, state=state)
64+
if not final_text:
65+
return []
66+
metadata = dict(update.metadata or {})
67+
return [
68+
GatewayStreamUpdate(
69+
kind=update.kind,
70+
text=final_text,
71+
metadata=metadata,
72+
media=list(getattr(update, "media", None) or metadata.get("_media") or []),
73+
)
74+
]
75+
if update.kind == "error":
76+
return [update]
77+
if normalized_mode == "silent":
78+
return []
79+
if update.kind == "progress":
80+
return _normalize_progress(update, content=content, state=state)
81+
if update.kind == "tool_hint":
82+
return _normalize_tool_hint(update, content=content, state=state)
83+
return [update]
84+
85+
86+
def normalize_final_reply(reply: str, *, state: GatewayOutputState) -> str:
87+
"""Remove local paths already represented as media from a final reply."""
88+
text, urls = _protect_urls(reply or "")
89+
text = _remove_path_code_blocks(text, state)
90+
text = _LOCAL_PATH_RE.sub(lambda match: _replace_local_path(match.group("path"), state), text)
91+
text = _restore_urls(text, urls)
92+
text = _clean_text(text)
93+
if not text and state.saw_media:
94+
return "已生成图片。" if state.has_image_media else "已生成文件。"
95+
if state.has_image_media and _only_acknowledges_generated_image(text):
96+
return "已生成图片。"
97+
return text
98+
99+
100+
def _normalize_progress(
101+
update: GatewayStreamUpdate,
102+
*,
103+
content: str,
104+
state: GatewayOutputState,
105+
) -> list[GatewayStreamUpdate]:
106+
if "thinking" in state.stage_keys:
107+
return []
108+
state.stage_keys.add("thinking")
109+
return [_copy_with_text(update, "正在处理..." if _prefers_chinese(content) else "Working on it...")]
110+
111+
112+
def _normalize_tool_hint(
113+
update: GatewayStreamUpdate,
114+
*,
115+
content: str,
116+
state: GatewayOutputState,
117+
) -> list[GatewayStreamUpdate]:
118+
metadata = update.metadata or {}
119+
tool_name = str(metadata.get("_tool_name") or "").strip()
120+
stage = _stage_for_tool(tool_name)
121+
if not stage:
122+
return []
123+
if stage in state.stage_keys:
124+
return []
125+
state.stage_keys.add(stage)
126+
text = _stage_text(stage, content)
127+
return [_copy_with_text(update, text)]
128+
129+
130+
def _stage_for_tool(tool_name: str) -> str | None:
131+
normalized = tool_name.strip().lower().replace("-", "_")
132+
if normalized in {"web_search", "web_fetch"}:
133+
return "searching"
134+
if normalized == "image_generation":
135+
return "generating_image"
136+
if normalized in {"shell", "bash", "python", "run_command"}:
137+
return "processing_files"
138+
return None
139+
140+
141+
def _stage_text(stage: str, content: str) -> str:
142+
zh = _prefers_chinese(content)
143+
if stage == "searching":
144+
return "正在检索资料..." if zh else "Searching..."
145+
if stage == "generating_image":
146+
return "正在生成图片..." if zh else "Generating image..."
147+
if stage == "processing_files":
148+
return "正在处理文件..." if zh else "Processing files..."
149+
return "正在处理..." if zh else "Working on it..."
150+
151+
152+
def _copy_with_text(update: GatewayStreamUpdate, text: str) -> GatewayStreamUpdate:
153+
return GatewayStreamUpdate(
154+
kind=update.kind,
155+
text=text,
156+
metadata=dict(update.metadata or {}),
157+
media=list(getattr(update, "media", None) or []),
158+
)
159+
160+
161+
def _strip_media_caption(update: GatewayStreamUpdate) -> GatewayStreamUpdate:
162+
return GatewayStreamUpdate(
163+
kind=update.kind,
164+
text="",
165+
metadata=dict(update.metadata or {}),
166+
media=list(getattr(update, "media", None) or []),
167+
)
168+
169+
170+
def _remember_update_media(state: GatewayOutputState, update: GatewayStreamUpdate) -> None:
171+
raw_media = getattr(update, "media", None) or (update.metadata or {}).get("_media") or []
172+
if isinstance(raw_media, str):
173+
candidates = [raw_media]
174+
elif isinstance(raw_media, list):
175+
candidates = [str(item) for item in raw_media if isinstance(item, str) and item.strip()]
176+
else:
177+
candidates = []
178+
for raw in candidates:
179+
state.saw_media = True
180+
resolved = _normalize_path(raw)
181+
state.media_paths.add(resolved)
182+
if Path(raw).suffix.lower() in _IMAGE_SUFFIXES:
183+
state.has_image_media = True
184+
185+
186+
def _remove_path_code_blocks(text: str, state: GatewayOutputState) -> str:
187+
def replace(match: re.Match[str]) -> str:
188+
body = match.group("body").strip()
189+
lines = [line.strip() for line in body.splitlines() if line.strip()]
190+
if lines and all(_looks_like_local_path(line) for line in lines):
191+
kept = [_replace_local_path(line, state) for line in lines]
192+
kept = [item for item in kept if item]
193+
return "\n".join(kept)
194+
return match.group(0)
195+
196+
return _PATH_CODE_BLOCK_RE.sub(replace, text)
197+
198+
199+
def _replace_local_path(path_text: str, state: GatewayOutputState) -> str:
200+
cleaned = path_text.strip().rstrip(".,;:,。;:、)]}")
201+
normalized = _normalize_path(cleaned)
202+
if normalized in state.media_paths:
203+
return ""
204+
path = Path(cleaned)
205+
return path.name if path.name else ""
206+
207+
208+
def _normalize_path(path_text: str) -> str:
209+
try:
210+
return str(Path(path_text).expanduser().resolve(strict=False))
211+
except Exception:
212+
return path_text
213+
214+
215+
def _looks_like_local_path(text: str) -> bool:
216+
return bool(_LOCAL_PATH_RE.fullmatch(text.strip()))
217+
218+
219+
def _clean_text(text: str) -> str:
220+
lines = [line.rstrip() for line in text.replace("\r\n", "\n").split("\n")]
221+
cleaned: list[str] = []
222+
blank = False
223+
for line in lines:
224+
stripped = line.strip()
225+
if not stripped:
226+
if cleaned and not blank:
227+
cleaned.append("")
228+
blank = True
229+
continue
230+
cleaned.append(stripped)
231+
blank = False
232+
return "\n".join(cleaned).strip()
233+
234+
235+
def _protect_urls(text: str) -> tuple[str, dict[str, str]]:
236+
urls: dict[str, str] = {}
237+
238+
def replace(match: re.Match[str]) -> str:
239+
key = f"__OHMO_URL_{len(urls)}__"
240+
urls[key] = match.group(0)
241+
return key
242+
243+
return _URL_RE.sub(replace, text), urls
244+
245+
246+
def _restore_urls(text: str, urls: dict[str, str]) -> str:
247+
for key, value in urls.items():
248+
text = text.replace(key, value)
249+
return text
250+
251+
252+
def _only_acknowledges_generated_image(text: str) -> bool:
253+
normalized = re.sub(r"\s+", "", text or "")
254+
return normalized in {
255+
"已生成图片:",
256+
"已生成图片:",
257+
"已生成图片。",
258+
"已生成图片",
259+
"已生成信息图:",
260+
"已生成信息图:",
261+
"已生成信息图。",
262+
"已生成信息图",
263+
}
264+
265+
266+
def _prefers_chinese(content: str) -> bool:
267+
return bool(re.search(r"[\u4e00-\u9fff]", content or ""))
268+
269+
270+
def _normalize_mode(mode: str) -> str:
271+
normalized = (mode or "user").strip().lower()
272+
if normalized in {"debug", "silent", "user"}:
273+
return normalized
274+
return "user"

0 commit comments

Comments
 (0)