|
1 | 1 | # -*- coding: utf-8 -*- |
2 | 2 | """Miscellaneous command handlers (trust, history, report).""" |
3 | 3 | # pylint: disable=protected-access,too-many-branches,too-many-statements |
| 4 | +# pylint: disable=too-many-return-statements |
4 | 5 |
|
5 | 6 | from __future__ import annotations |
6 | 7 |
|
| 8 | +from typing import Any |
| 9 | + |
7 | 10 | from rich.console import Console |
| 11 | +from rich.markup import escape |
8 | 12 |
|
9 | 13 | from dashscope.acli.agent import Agent |
10 | 14 |
|
@@ -77,6 +81,86 @@ def _handle_trust_command(cmd: str, agent: Agent) -> None: |
77 | 81 | ) |
78 | 82 |
|
79 | 83 |
|
| 84 | +def _message_text(msg: dict[str, Any]) -> str: |
| 85 | + """Flatten a chat message's content into one-line plain text.""" |
| 86 | + content = msg.get("content", "") |
| 87 | + if isinstance(content, list): |
| 88 | + content = " ".join( |
| 89 | + part.get("text", "") for part in content if isinstance(part, dict) |
| 90 | + ) |
| 91 | + if not isinstance(content, str): |
| 92 | + return "" |
| 93 | + return " ".join(content.split()) |
| 94 | + |
| 95 | + |
| 96 | +def _search_snippet(text: str, idx: int, kw_len: int) -> str: |
| 97 | + """Build a short one-line snippet centered on a match position.""" |
| 98 | + start = max(0, idx - 20) |
| 99 | + end = min(len(text), idx + kw_len + 40) |
| 100 | + prefix = "..." if start > 0 else "" |
| 101 | + suffix = "..." if end < len(text) else "" |
| 102 | + return prefix + text[start:end] + suffix |
| 103 | + |
| 104 | + |
| 105 | +def _highlight_keyword(text: str, keyword: str) -> str: |
| 106 | + """Wrap keyword occurrences in rich markup (case-insensitive).""" |
| 107 | + lower_kw = keyword.lower() |
| 108 | + if not lower_kw: |
| 109 | + return escape(text) |
| 110 | + lower_text = text.lower() |
| 111 | + out: list[str] = [] |
| 112 | + pos = 0 |
| 113 | + while True: |
| 114 | + idx = lower_text.find(lower_kw, pos) |
| 115 | + if idx < 0: |
| 116 | + out.append(escape(text[pos:])) |
| 117 | + break |
| 118 | + out.append(escape(text[pos:idx])) |
| 119 | + out.append("[bold yellow]") |
| 120 | + out.append(escape(text[idx : idx + len(keyword)])) |
| 121 | + out.append("[/bold yellow]") |
| 122 | + pos = idx + len(keyword) |
| 123 | + return "".join(out) |
| 124 | + |
| 125 | + |
| 126 | +def _history_search_matches( |
| 127 | + keyword: str, |
| 128 | + limit: int = 20, |
| 129 | +) -> list[dict[str, str]]: |
| 130 | + """Case-insensitive substring search across all session history. |
| 131 | +
|
| 132 | + Scans every stored session's messages and returns up to ``limit`` |
| 133 | + matches, each carrying the session topic, a timestamp, the message |
| 134 | + role, and a one-line snippet with match context. |
| 135 | + """ |
| 136 | + from dashscope.acli.session import get_session_manager |
| 137 | + |
| 138 | + needle = keyword.lower() |
| 139 | + if not needle or limit <= 0: |
| 140 | + return [] |
| 141 | + mgr = get_session_manager() |
| 142 | + matches: list[dict[str, str]] = [] |
| 143 | + for meta in mgr.list_topics(): |
| 144 | + if len(matches) >= limit: |
| 145 | + break |
| 146 | + for msg in mgr.load_messages(meta.topic): |
| 147 | + text = _message_text(msg) |
| 148 | + idx = text.lower().find(needle) if text else -1 |
| 149 | + if idx < 0: |
| 150 | + continue |
| 151 | + matches.append( |
| 152 | + { |
| 153 | + "session": meta.topic, |
| 154 | + "timestamp": meta.last_accessed or "", |
| 155 | + "role": str(msg.get("role", "?")), |
| 156 | + "snippet": _search_snippet(text, idx, len(needle)), |
| 157 | + }, |
| 158 | + ) |
| 159 | + if len(matches) >= limit: |
| 160 | + break |
| 161 | + return matches |
| 162 | + |
| 163 | + |
80 | 164 | def _handle_history_command(cmd: str) -> None: |
81 | 165 | """Manage conversation history.""" |
82 | 166 | from dashscope.acli.platforms.local.history import ( |
@@ -107,6 +191,8 @@ def _handle_history_command(cmd: str) -> None: |
107 | 191 | "\n[dim]Usage:\n" |
108 | 192 | " /history stats — show stats\n" |
109 | 193 | " /history list [n] — list recent n\n" |
| 194 | + " /history search <keyword> [limit] — full-text " |
| 195 | + "search\n" |
110 | 196 | " /history export <file> [--format json|md|html] — export\n" |
111 | 197 | " /history clear — clear " |
112 | 198 | "history[/dim]", |
@@ -147,6 +233,41 @@ def _handle_history_command(cmd: str) -> None: |
147 | 233 | ) |
148 | 234 | return |
149 | 235 |
|
| 236 | + if sub == "search": |
| 237 | + if len(parts) < 3: |
| 238 | + console.print( |
| 239 | + "[dim]Usage: /history search <keyword> [limit][/dim]", |
| 240 | + ) |
| 241 | + return |
| 242 | + keyword = parts[2] |
| 243 | + limit = 20 |
| 244 | + if len(parts) >= 4: |
| 245 | + try: |
| 246 | + limit = int(parts[3]) |
| 247 | + except ValueError: |
| 248 | + limit = 0 |
| 249 | + if limit <= 0: |
| 250 | + console.print( |
| 251 | + "[red]limit must be a positive integer[/red]", |
| 252 | + ) |
| 253 | + return |
| 254 | + matches = _history_search_matches(keyword, limit=limit) |
| 255 | + if not matches: |
| 256 | + console.print(f"[dim]No matches for '{keyword}'[/dim]") |
| 257 | + return |
| 258 | + header = f"[bold]{len(matches)} match(es) for '{keyword}':[/bold]" |
| 259 | + console.print(header) |
| 260 | + for i, m in enumerate(matches, 1): |
| 261 | + ts = m["timestamp"][:16] |
| 262 | + head = ( |
| 263 | + f" {i}. [cyan]{m['session']}[/cyan] " |
| 264 | + f"[dim]{ts}[/dim] [bold]{m['role']}[/bold]" |
| 265 | + ) |
| 266 | + console.print(head) |
| 267 | + snippet = _highlight_keyword(m["snippet"], keyword) |
| 268 | + console.print(f" {snippet}") |
| 269 | + return |
| 270 | + |
150 | 271 | if sub == "export" and len(parts) >= 3: |
151 | 272 | output_path = parts[2] |
152 | 273 | fmt = "html" |
@@ -174,7 +295,8 @@ def _handle_history_command(cmd: str) -> None: |
174 | 295 | console.print(f"[green]✓ Cleared {count} history records[/green]") |
175 | 296 | return |
176 | 297 |
|
177 | | - console.print("[dim]Usage: /history [stats|list|export|clear][/dim]") |
| 298 | + usage = "[dim]Usage: /history [stats|list|search|export|clear][/dim]" |
| 299 | + console.print(usage) |
178 | 300 |
|
179 | 301 |
|
180 | 302 | def _handle_report_command(agent: Agent) -> None: |
|
0 commit comments