entari-plugin-hyw 4.0.0rc10__py3-none-any.whl → 4.0.0rc12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- entari_plugin_hyw/__init__.py +191 -140
- entari_plugin_hyw/filters.py +83 -0
- entari_plugin_hyw/misc.py +42 -0
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/METADATA +1 -1
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/RECORD +14 -12
- hyw_core/agent.py +648 -0
- hyw_core/browser_control/service.py +283 -130
- hyw_core/core.py +148 -8
- hyw_core/crawling/completeness.py +99 -10
- hyw_core/definitions.py +70 -52
- hyw_core/search.py +10 -0
- hyw_core/stages/summary.py +1 -3
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/top_level.txt +0 -0
hyw_core/agent.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Pipeline
|
|
3
|
+
|
|
4
|
+
Tool-calling agent that can autonomously use web_tool to search/screenshot.
|
|
5
|
+
Maximum 2 tool calls, then forced summary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Callable, Awaitable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
from openai import AsyncOpenAI
|
|
17
|
+
|
|
18
|
+
from .definitions import get_web_tool, get_refuse_answer_tool, AGENT_SYSTEM_PROMPT
|
|
19
|
+
from .stages.base import StageContext, StageResult
|
|
20
|
+
from .search import SearchService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AgentSession:
|
|
25
|
+
"""Agent session with tool call tracking."""
|
|
26
|
+
session_id: str
|
|
27
|
+
user_query: str
|
|
28
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
29
|
+
tool_results: List[Dict[str, Any]] = field(default_factory=list)
|
|
30
|
+
conversation_history: List[Dict] = field(default_factory=list)
|
|
31
|
+
messages: List[Dict] = field(default_factory=list) # LLM conversation
|
|
32
|
+
created_at: float = field(default_factory=time.time)
|
|
33
|
+
|
|
34
|
+
# Image tracking
|
|
35
|
+
user_image_count: int = 0 # Number of images from user input
|
|
36
|
+
total_image_count: int = 0 # Total images including web screenshots
|
|
37
|
+
|
|
38
|
+
# Time tracking
|
|
39
|
+
search_time: float = 0.0 # Total time spent on search/screenshot
|
|
40
|
+
llm_time: float = 0.0 # Total time spent on LLM calls
|
|
41
|
+
first_llm_time: float = 0.0 # Time for first LLM call (understanding intent)
|
|
42
|
+
|
|
43
|
+
# Usage tracking
|
|
44
|
+
usage_totals: Dict[str, int] = field(default_factory=lambda: {"input_tokens": 0, "output_tokens": 0})
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def call_count(self) -> int:
|
|
48
|
+
return len(self.tool_calls)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def should_force_summary(self) -> bool:
|
|
52
|
+
"""第3次调用时强制总结"""
|
|
53
|
+
return self.call_count >= 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_filter_syntax(query: str, max_count: int = 3):
|
|
57
|
+
"""
|
|
58
|
+
Parse enhanced filter syntax supporting:
|
|
59
|
+
- Chinese/English colons (: :) and commas (, ,)
|
|
60
|
+
- Multiple filters: "mcmod=2, github=1 : xxx"
|
|
61
|
+
- Index lists: "1, 2, 3 : xxx"
|
|
62
|
+
- Max total selections
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
filters: list of (filter_type, filter_value, count) tuples
|
|
66
|
+
filter_type: 'index' or 'link'
|
|
67
|
+
filter_value: int (for index) or str (for link match term)
|
|
68
|
+
count: how many to get (default 1)
|
|
69
|
+
search_query: the actual search query
|
|
70
|
+
error_msg: error message if exceeded max
|
|
71
|
+
"""
|
|
72
|
+
import re
|
|
73
|
+
|
|
74
|
+
# Skip filter parsing if query contains URL (has :// pattern)
|
|
75
|
+
if re.search(r'https?://', query):
|
|
76
|
+
return [], query.strip(), None
|
|
77
|
+
|
|
78
|
+
# Normalize colons
|
|
79
|
+
query = query.replace(':', ':')
|
|
80
|
+
|
|
81
|
+
if ':' not in query:
|
|
82
|
+
return [], query.strip(), None
|
|
83
|
+
|
|
84
|
+
parts = query.split(':', 1)
|
|
85
|
+
if len(parts) != 2:
|
|
86
|
+
return [], query.strip(), None
|
|
87
|
+
|
|
88
|
+
filter_part = parts[0].strip()
|
|
89
|
+
search_query = parts[1].strip()
|
|
90
|
+
|
|
91
|
+
if not filter_part or not search_query:
|
|
92
|
+
return [], query.strip(), None
|
|
93
|
+
|
|
94
|
+
# Parse filter expressions
|
|
95
|
+
filters = []
|
|
96
|
+
total_count = 0
|
|
97
|
+
|
|
98
|
+
# Normalize commas
|
|
99
|
+
filter_part = filter_part.replace(',', ',').replace('、', ',')
|
|
100
|
+
filter_items = [f.strip() for f in filter_part.split(',') if f.strip()]
|
|
101
|
+
|
|
102
|
+
for item in filter_items:
|
|
103
|
+
# Check for "term=count" format (link filter)
|
|
104
|
+
if '=' in item:
|
|
105
|
+
term, count_str = item.split('=', 1)
|
|
106
|
+
term = term.strip().lower()
|
|
107
|
+
try:
|
|
108
|
+
count = int(count_str.strip())
|
|
109
|
+
except ValueError:
|
|
110
|
+
count = 1
|
|
111
|
+
if term and count > 0:
|
|
112
|
+
filters.append(('link', term, count))
|
|
113
|
+
total_count += count
|
|
114
|
+
# Check for pure number (index filter)
|
|
115
|
+
elif item.isdigit():
|
|
116
|
+
idx = int(item)
|
|
117
|
+
if 1 <= idx <= 10:
|
|
118
|
+
filters.append(('index', idx, 1))
|
|
119
|
+
total_count += 1
|
|
120
|
+
|
|
121
|
+
if total_count > max_count:
|
|
122
|
+
return None, search_query, f"⚠️ 最多选择{max_count}个结果"
|
|
123
|
+
|
|
124
|
+
return filters, search_query, None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AgentPipeline:
|
|
128
|
+
"""
|
|
129
|
+
Tool-calling agent pipeline.
|
|
130
|
+
|
|
131
|
+
Flow:
|
|
132
|
+
1. 用户输入 → LLM (with tools)
|
|
133
|
+
2. If tool_call: execute tool → notify user → loop
|
|
134
|
+
3. If call_count >= 2: force summary on next call
|
|
135
|
+
4. Return final content
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
MAX_TOOL_CALLS = 2
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
config: Any,
|
|
143
|
+
search_service: SearchService,
|
|
144
|
+
send_func: Optional[Callable[[str], Awaitable[None]]] = None
|
|
145
|
+
):
|
|
146
|
+
self.config = config
|
|
147
|
+
self.search_service = search_service
|
|
148
|
+
self.send_func = send_func
|
|
149
|
+
self.client = AsyncOpenAI(base_url=config.base_url, api_key=config.api_key)
|
|
150
|
+
|
|
151
|
+
async def execute(
|
|
152
|
+
self,
|
|
153
|
+
user_input: str,
|
|
154
|
+
conversation_history: List[Dict],
|
|
155
|
+
images: List[str] = None,
|
|
156
|
+
model_name: str = None,
|
|
157
|
+
) -> Dict[str, Any]:
|
|
158
|
+
"""Execute agent with tool-calling loop."""
|
|
159
|
+
start_time = time.time()
|
|
160
|
+
|
|
161
|
+
# Get model config
|
|
162
|
+
model_cfg = self.config.get_model_config("main")
|
|
163
|
+
model = model_name or model_cfg.model_name or self.config.model_name
|
|
164
|
+
|
|
165
|
+
client = AsyncOpenAI(
|
|
166
|
+
base_url=model_cfg.base_url or self.config.base_url,
|
|
167
|
+
api_key=model_cfg.api_key or self.config.api_key
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Create session
|
|
171
|
+
session = AgentSession(
|
|
172
|
+
session_id=str(time.time()),
|
|
173
|
+
user_query=user_input,
|
|
174
|
+
conversation_history=conversation_history.copy()
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Create context for results
|
|
178
|
+
context = StageContext(
|
|
179
|
+
user_input=user_input,
|
|
180
|
+
images=images or [],
|
|
181
|
+
conversation_history=conversation_history,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Build initial messages
|
|
185
|
+
language = getattr(self.config, "language", "Simplified Chinese")
|
|
186
|
+
system_prompt = AGENT_SYSTEM_PROMPT + f"\n\n用户要求的语言: {language}"
|
|
187
|
+
|
|
188
|
+
# Build user content with images if provided
|
|
189
|
+
user_image_count = len(images) if images else 0
|
|
190
|
+
session.user_image_count = user_image_count
|
|
191
|
+
session.total_image_count = user_image_count
|
|
192
|
+
|
|
193
|
+
if images:
|
|
194
|
+
user_content: List[Dict[str, Any]] = [{"type": "text", "text": user_input}]
|
|
195
|
+
for img_b64 in images:
|
|
196
|
+
url = f"data:image/jpeg;base64,{img_b64}" if not img_b64.startswith("data:") else img_b64
|
|
197
|
+
user_content.append({"type": "image_url", "image_url": {"url": url}})
|
|
198
|
+
else:
|
|
199
|
+
user_content = user_input
|
|
200
|
+
|
|
201
|
+
session.messages = [
|
|
202
|
+
{"role": "system", "content": system_prompt},
|
|
203
|
+
{"role": "user", "content": user_content}
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
# Add image source hint for user images
|
|
207
|
+
if user_image_count > 0:
|
|
208
|
+
if user_image_count == 1:
|
|
209
|
+
hint = "第1张图片来自用户输入,请将这张图片作为用户输入的参考"
|
|
210
|
+
else:
|
|
211
|
+
hint = f"第1-{user_image_count}张图片来自用户输入,请将这{user_image_count}张图片作为用户输入的参考"
|
|
212
|
+
session.messages.append({"role": "system", "content": hint})
|
|
213
|
+
|
|
214
|
+
# Tool definitions
|
|
215
|
+
web_tool = get_web_tool()
|
|
216
|
+
refuse_tool = get_refuse_answer_tool()
|
|
217
|
+
tools = [web_tool, refuse_tool]
|
|
218
|
+
|
|
219
|
+
usage_totals = {"input_tokens": 0, "output_tokens": 0}
|
|
220
|
+
final_content = ""
|
|
221
|
+
|
|
222
|
+
# Send initial status notification
|
|
223
|
+
if self.send_func:
|
|
224
|
+
try:
|
|
225
|
+
await self.send_func("💭 正在理解用户意图...")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"AgentPipeline: Failed to send initial notification: {e}")
|
|
228
|
+
|
|
229
|
+
# Agent loop
|
|
230
|
+
while True:
|
|
231
|
+
# Check if we need to force summary (no tools)
|
|
232
|
+
if session.should_force_summary:
|
|
233
|
+
logger.info(f"AgentPipeline: Max tool calls ({self.MAX_TOOL_CALLS}) reached, forcing summary")
|
|
234
|
+
# Add context message about collected info
|
|
235
|
+
if context.web_results:
|
|
236
|
+
context_msg = self._format_web_context(context)
|
|
237
|
+
session.messages.append({
|
|
238
|
+
"role": "system",
|
|
239
|
+
"content": f"你已经完成了{session.call_count}次工具调用。请基于已收集的信息给出最终回答。\n\n{context_msg}"
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Final call without tools
|
|
244
|
+
response = await client.chat.completions.create(
|
|
245
|
+
model=model,
|
|
246
|
+
messages=session.messages,
|
|
247
|
+
temperature=self.config.temperature,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if response.usage:
|
|
251
|
+
usage_totals["input_tokens"] += response.usage.prompt_tokens or 0
|
|
252
|
+
usage_totals["output_tokens"] += response.usage.completion_tokens or 0
|
|
253
|
+
|
|
254
|
+
final_content = response.choices[0].message.content or ""
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
# Normal call with tools
|
|
258
|
+
llm_start = time.time()
|
|
259
|
+
try:
|
|
260
|
+
response = await client.chat.completions.create(
|
|
261
|
+
model=model,
|
|
262
|
+
messages=session.messages,
|
|
263
|
+
temperature=self.config.temperature,
|
|
264
|
+
tools=tools,
|
|
265
|
+
tool_choice="auto",
|
|
266
|
+
)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"AgentPipeline: LLM error: {e}")
|
|
269
|
+
return {
|
|
270
|
+
"llm_response": f"Error: {e}",
|
|
271
|
+
"success": False,
|
|
272
|
+
"error": str(e),
|
|
273
|
+
"stats": {"total_time": time.time() - start_time}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
llm_duration = time.time() - llm_start
|
|
277
|
+
session.llm_time += llm_duration
|
|
278
|
+
|
|
279
|
+
# Track first LLM call time (理解用户意图)
|
|
280
|
+
if session.call_count == 0 and session.first_llm_time == 0:
|
|
281
|
+
session.first_llm_time = llm_duration
|
|
282
|
+
|
|
283
|
+
if response.usage:
|
|
284
|
+
usage_totals["input_tokens"] += response.usage.prompt_tokens or 0
|
|
285
|
+
usage_totals["output_tokens"] += response.usage.completion_tokens or 0
|
|
286
|
+
|
|
287
|
+
message = response.choices[0].message
|
|
288
|
+
|
|
289
|
+
# Check for tool calls
|
|
290
|
+
if not message.tool_calls:
|
|
291
|
+
# Model chose to answer directly
|
|
292
|
+
final_content = message.content or ""
|
|
293
|
+
logger.info(f"AgentPipeline: Model answered directly after {session.call_count} tool calls")
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
# Add assistant message with tool calls
|
|
297
|
+
session.messages.append({
|
|
298
|
+
"role": "assistant",
|
|
299
|
+
"content": message.content,
|
|
300
|
+
"tool_calls": [
|
|
301
|
+
{
|
|
302
|
+
"id": tc.id,
|
|
303
|
+
"type": "function",
|
|
304
|
+
"function": {"name": tc.function.name, "arguments": tc.function.arguments}
|
|
305
|
+
}
|
|
306
|
+
for tc in message.tool_calls
|
|
307
|
+
]
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
# Execute tool calls
|
|
311
|
+
for tool_call in message.tool_calls:
|
|
312
|
+
tc_id = tool_call.id
|
|
313
|
+
func_name = tool_call.function.name
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
args = json.loads(tool_call.function.arguments)
|
|
317
|
+
except json.JSONDecodeError:
|
|
318
|
+
args = {}
|
|
319
|
+
|
|
320
|
+
logger.info(f"AgentPipeline: Executing tool '{func_name}' with args: {args}")
|
|
321
|
+
|
|
322
|
+
if func_name == "refuse_answer":
|
|
323
|
+
# Handle refusal
|
|
324
|
+
reason = args.get("reason", "Refused")
|
|
325
|
+
context.should_refuse = True
|
|
326
|
+
context.refuse_reason = reason
|
|
327
|
+
|
|
328
|
+
session.messages.append({
|
|
329
|
+
"role": "tool",
|
|
330
|
+
"tool_call_id": tc_id,
|
|
331
|
+
"content": f"已拒绝回答: {reason}"
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"llm_response": "",
|
|
336
|
+
"success": True,
|
|
337
|
+
"refuse_answer": True,
|
|
338
|
+
"refuse_reason": reason,
|
|
339
|
+
"stats": {"total_time": time.time() - start_time},
|
|
340
|
+
"usage": usage_totals,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
elif func_name == "web_tool":
|
|
344
|
+
# Execute web tool with time tracking
|
|
345
|
+
search_start = time.time()
|
|
346
|
+
result = await self._execute_web_tool(args, context)
|
|
347
|
+
session.search_time += time.time() - search_start
|
|
348
|
+
|
|
349
|
+
# Track tool call
|
|
350
|
+
session.tool_calls.append({"name": func_name, "args": args})
|
|
351
|
+
session.tool_results.append(result)
|
|
352
|
+
|
|
353
|
+
# Send IM notification with search result (NOT "正在搜索...")
|
|
354
|
+
if self.send_func:
|
|
355
|
+
try:
|
|
356
|
+
await self.send_func(f"🔍 {result['summary']}")
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.warning(f"AgentPipeline: Failed to send notification: {e}")
|
|
359
|
+
|
|
360
|
+
# Add tool result to messages
|
|
361
|
+
result_content = f"搜索完成: {result['summary']}\n\n找到 {len(result.get('results', []))} 个结果"
|
|
362
|
+
session.messages.append({
|
|
363
|
+
"role": "tool",
|
|
364
|
+
"tool_call_id": tc_id,
|
|
365
|
+
"content": result_content
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
# Add image source hint for web screenshots
|
|
369
|
+
screenshot_count = result.get("screenshot_count", 0)
|
|
370
|
+
if screenshot_count > 0:
|
|
371
|
+
start_idx = session.total_image_count + 1
|
|
372
|
+
end_idx = session.total_image_count + screenshot_count
|
|
373
|
+
session.total_image_count = end_idx
|
|
374
|
+
|
|
375
|
+
source_desc = result.get("source_desc", "网页截图")
|
|
376
|
+
if start_idx == end_idx:
|
|
377
|
+
hint = f"第{start_idx}张图片来自{source_desc},作为查询的参考资料"
|
|
378
|
+
else:
|
|
379
|
+
hint = f"第{start_idx}-{end_idx}张图片来自{source_desc},作为查询的参考资料"
|
|
380
|
+
session.messages.append({"role": "system", "content": hint})
|
|
381
|
+
else:
|
|
382
|
+
# Unknown tool
|
|
383
|
+
session.messages.append({
|
|
384
|
+
"role": "tool",
|
|
385
|
+
"tool_call_id": tc_id,
|
|
386
|
+
"content": f"Unknown tool: {func_name}"
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
# Build final response
|
|
390
|
+
total_time = time.time() - start_time
|
|
391
|
+
stats = {"total_time": total_time}
|
|
392
|
+
|
|
393
|
+
# Update conversation history
|
|
394
|
+
conversation_history.append({"role": "user", "content": user_input})
|
|
395
|
+
conversation_history.append({"role": "assistant", "content": final_content})
|
|
396
|
+
|
|
397
|
+
stages_used = self._build_stages_ui(session, context, usage_totals, total_time)
|
|
398
|
+
logger.info(f"AgentPipeline: Built stages_used = {stages_used}")
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"llm_response": final_content,
|
|
402
|
+
"success": True,
|
|
403
|
+
"stats": stats,
|
|
404
|
+
"model_used": model,
|
|
405
|
+
"conversation_history": conversation_history,
|
|
406
|
+
"usage": usage_totals,
|
|
407
|
+
"web_results": context.web_results,
|
|
408
|
+
"tool_calls_count": session.call_count,
|
|
409
|
+
"stages_used": stages_used,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async def _execute_web_tool(self, args: Dict, context: StageContext) -> Dict[str, Any]:
|
|
413
|
+
"""执行 web_tool - 复用 /w 逻辑,支持过滤器语法"""
|
|
414
|
+
query = args.get("query", "")
|
|
415
|
+
|
|
416
|
+
# 1. URL 截图模式 - 检测 query 中是否包含 URL
|
|
417
|
+
url_match = re.search(r'https?://\S+', query)
|
|
418
|
+
if url_match:
|
|
419
|
+
url = url_match.group(0)
|
|
420
|
+
# Send URL screenshot notification
|
|
421
|
+
if self.send_func:
|
|
422
|
+
try:
|
|
423
|
+
short_url = url[:40] + "..." if len(url) > 40 else url
|
|
424
|
+
await self.send_func(f"📸 正在截图: {short_url}")
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
logger.info(f"AgentPipeline: Screenshot URL with content: {url}")
|
|
429
|
+
# Use screenshot_with_content to get both screenshot and text
|
|
430
|
+
result = await self.search_service.screenshot_with_content(url)
|
|
431
|
+
screenshot_b64 = result.get("screenshot_b64")
|
|
432
|
+
content = result.get("content", "")
|
|
433
|
+
title = result.get("title", "")
|
|
434
|
+
|
|
435
|
+
if screenshot_b64:
|
|
436
|
+
context.web_results.append({
|
|
437
|
+
"_id": context.next_id(),
|
|
438
|
+
"_type": "page",
|
|
439
|
+
"url": url,
|
|
440
|
+
"title": title or "Screenshot",
|
|
441
|
+
"screenshot_b64": screenshot_b64,
|
|
442
|
+
"content": content, # Text content for LLM
|
|
443
|
+
})
|
|
444
|
+
return {
|
|
445
|
+
"summary": f"已截图: {url[:50]}{'...' if len(url) > 50 else ''}",
|
|
446
|
+
"results": [{"_type": "screenshot", "url": url}],
|
|
447
|
+
"screenshot_count": 1,
|
|
448
|
+
"source_desc": f"URL截图 ({url[:30]}...)"
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
"summary": f"截图失败: {url[:50]}",
|
|
452
|
+
"results": [],
|
|
453
|
+
"screenshot_count": 0
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# 2. 解析过滤器语法
|
|
457
|
+
filters, search_query, error = parse_filter_syntax(query, max_count=3)
|
|
458
|
+
|
|
459
|
+
if error:
|
|
460
|
+
return {"summary": error, "results": []}
|
|
461
|
+
|
|
462
|
+
# 3. 如果有过滤器,发送搜索+截图预告
|
|
463
|
+
if filters and self.send_func:
|
|
464
|
+
try:
|
|
465
|
+
# Build filter description
|
|
466
|
+
filter_desc_parts = []
|
|
467
|
+
for f_type, f_val, f_count in filters:
|
|
468
|
+
if f_type == 'index':
|
|
469
|
+
filter_desc_parts.append(f"第{f_val}个")
|
|
470
|
+
else:
|
|
471
|
+
filter_desc_parts.append(f"{f_val}={f_count}")
|
|
472
|
+
filter_desc = ", ".join(filter_desc_parts)
|
|
473
|
+
await self.send_func(f"🔍 正在搜索 \"{search_query}\" 并匹配 [{filter_desc}]...")
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
logger.info(f"AgentPipeline: Searching for: {search_query}")
|
|
478
|
+
results = await self.search_service.search(search_query)
|
|
479
|
+
visible = [r for r in results if not r.get("_hidden")]
|
|
480
|
+
|
|
481
|
+
# Add search results to context
|
|
482
|
+
for r in results:
|
|
483
|
+
r["_id"] = context.next_id()
|
|
484
|
+
if "_type" not in r:
|
|
485
|
+
r["_type"] = "search"
|
|
486
|
+
r["query"] = search_query
|
|
487
|
+
context.web_results.append(r)
|
|
488
|
+
|
|
489
|
+
# 4. 如果有过滤器,截图匹配的链接
|
|
490
|
+
if filters:
|
|
491
|
+
urls = self._collect_filter_urls(filters, visible)
|
|
492
|
+
if urls:
|
|
493
|
+
logger.info(f"AgentPipeline: Taking screenshots with content of {len(urls)} URLs")
|
|
494
|
+
# Use screenshot_with_content to get both screenshot and text
|
|
495
|
+
screenshot_tasks = [self.search_service.screenshot_with_content(u) for u in urls]
|
|
496
|
+
results = await asyncio.gather(*screenshot_tasks)
|
|
497
|
+
|
|
498
|
+
# Add screenshots and content to context
|
|
499
|
+
successful_count = 0
|
|
500
|
+
for url, result in zip(urls, results):
|
|
501
|
+
screenshot_b64 = result.get("screenshot_b64") if isinstance(result, dict) else None
|
|
502
|
+
content = result.get("content", "") if isinstance(result, dict) else ""
|
|
503
|
+
title = result.get("title", "") if isinstance(result, dict) else ""
|
|
504
|
+
|
|
505
|
+
if screenshot_b64:
|
|
506
|
+
successful_count += 1
|
|
507
|
+
# Find and update the matching result
|
|
508
|
+
for r in context.web_results:
|
|
509
|
+
if r.get("url") == url:
|
|
510
|
+
r["screenshot_b64"] = screenshot_b64
|
|
511
|
+
r["content"] = content # Text content for LLM
|
|
512
|
+
r["title"] = title or r.get("title", "")
|
|
513
|
+
r["_type"] = "page"
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
"summary": f"搜索 \"{search_query}\" 并截图 {successful_count} 个匹配结果",
|
|
518
|
+
"results": [{"url": u, "_type": "page"} for u in urls],
|
|
519
|
+
"screenshot_count": successful_count,
|
|
520
|
+
"source_desc": f"搜索 \"{search_query}\" 的网页截图"
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
# 5. 普通搜索模式 (无截图)
|
|
524
|
+
return {
|
|
525
|
+
"summary": f"搜索 \"{search_query}\" 找到 {len(visible)} 条结果",
|
|
526
|
+
"results": visible,
|
|
527
|
+
"screenshot_count": 0
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def _collect_filter_urls(self, filters: List, visible: List[Dict]) -> List[str]:
|
|
531
|
+
"""Collect URLs based on filter specifications."""
|
|
532
|
+
urls = []
|
|
533
|
+
|
|
534
|
+
for filter_type, filter_value, count in filters:
|
|
535
|
+
if filter_type == 'index':
|
|
536
|
+
idx = filter_value - 1 # Convert to 0-based
|
|
537
|
+
if 0 <= idx < len(visible):
|
|
538
|
+
url = visible[idx].get("url", "")
|
|
539
|
+
if url and url not in urls:
|
|
540
|
+
urls.append(url)
|
|
541
|
+
else:
|
|
542
|
+
# Link filter
|
|
543
|
+
found_count = 0
|
|
544
|
+
for res in visible:
|
|
545
|
+
url = res.get("url", "")
|
|
546
|
+
title = res.get("title", "")
|
|
547
|
+
# Match filter against both URL and title
|
|
548
|
+
if (filter_value in url.lower() or filter_value in title.lower()) and url not in urls:
|
|
549
|
+
urls.append(url)
|
|
550
|
+
found_count += 1
|
|
551
|
+
if found_count >= count:
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
return urls
|
|
555
|
+
|
|
556
|
+
def _format_web_context(self, context: StageContext) -> str:
|
|
557
|
+
"""Format web results for summary context."""
|
|
558
|
+
if not context.web_results:
|
|
559
|
+
return ""
|
|
560
|
+
|
|
561
|
+
lines = ["## 已收集的信息\n"]
|
|
562
|
+
for r in context.web_results:
|
|
563
|
+
idx = r.get("_id", "?")
|
|
564
|
+
title = r.get("title", "Untitled")
|
|
565
|
+
url = r.get("url", "")
|
|
566
|
+
content = r.get("content", "")[:500] if r.get("content") else ""
|
|
567
|
+
has_screenshot = "有截图" if r.get("screenshot_b64") else ""
|
|
568
|
+
|
|
569
|
+
lines.append(f"[{idx}] {title}")
|
|
570
|
+
if url:
|
|
571
|
+
lines.append(f" URL: {url}")
|
|
572
|
+
if has_screenshot:
|
|
573
|
+
lines.append(f" {has_screenshot}")
|
|
574
|
+
if content:
|
|
575
|
+
lines.append(f" 摘要: {content[:200]}...")
|
|
576
|
+
lines.append("")
|
|
577
|
+
|
|
578
|
+
return "\n".join(lines)
|
|
579
|
+
|
|
580
|
+
def _build_stages_ui(self, session: AgentSession, context: StageContext, usage_totals: Dict, total_time: float) -> List[Dict[str, Any]]:
|
|
581
|
+
"""Build stages UI for rendering - compatible with App.vue flow section.
|
|
582
|
+
|
|
583
|
+
Flow: Instruct (意图) → Search (搜索) → Summary (总结)
|
|
584
|
+
"""
|
|
585
|
+
stages = []
|
|
586
|
+
|
|
587
|
+
# Get model config for pricing
|
|
588
|
+
model_cfg = self.config.get_model_config("main")
|
|
589
|
+
model_name = model_cfg.model_name or self.config.model_name
|
|
590
|
+
input_price = getattr(model_cfg, "input_price", 0) or 0
|
|
591
|
+
output_price = getattr(model_cfg, "output_price", 0) or 0
|
|
592
|
+
|
|
593
|
+
# 1. Instruct Stage (理解用户意图 - 第一次LLM调用)
|
|
594
|
+
if session.first_llm_time > 0:
|
|
595
|
+
# Estimate tokens for first call (rough split based on proportion)
|
|
596
|
+
# Since we track total usage, we approximate first call as ~40% of total
|
|
597
|
+
first_call_ratio = 0.4 if session.call_count > 0 else 1.0
|
|
598
|
+
instruct_input = int(usage_totals.get("input_tokens", 0) * first_call_ratio)
|
|
599
|
+
instruct_output = int(usage_totals.get("output_tokens", 0) * first_call_ratio)
|
|
600
|
+
instruct_cost = (instruct_input * input_price + instruct_output * output_price) / 1_000_000
|
|
601
|
+
|
|
602
|
+
stages.append({
|
|
603
|
+
"name": "Instruct",
|
|
604
|
+
"model": model_name,
|
|
605
|
+
"provider": model_cfg.model_provider or "OpenRouter",
|
|
606
|
+
"description": "理解用户意图",
|
|
607
|
+
"time": session.first_llm_time,
|
|
608
|
+
"usage": {"input_tokens": instruct_input, "output_tokens": instruct_output},
|
|
609
|
+
"cost": instruct_cost,
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
# 2. Search Stage (搜索)
|
|
613
|
+
if session.tool_calls:
|
|
614
|
+
# Collect all search descriptions
|
|
615
|
+
search_descriptions = []
|
|
616
|
+
for tc, result in zip(session.tool_calls, session.tool_results):
|
|
617
|
+
desc = result.get("summary", "")
|
|
618
|
+
if desc:
|
|
619
|
+
search_descriptions.append(desc)
|
|
620
|
+
|
|
621
|
+
stages.append({
|
|
622
|
+
"name": "Search",
|
|
623
|
+
"model": "",
|
|
624
|
+
"provider": "Web",
|
|
625
|
+
"description": " → ".join(search_descriptions) if search_descriptions else "Web Search",
|
|
626
|
+
"time": session.search_time,
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
# 3. Summary Stage (总结)
|
|
630
|
+
# Calculate remaining tokens after instruct
|
|
631
|
+
summary_ratio = 0.6 if session.call_count > 0 else 0.0
|
|
632
|
+
summary_input = int(usage_totals.get("input_tokens", 0) * summary_ratio)
|
|
633
|
+
summary_output = int(usage_totals.get("output_tokens", 0) * summary_ratio)
|
|
634
|
+
summary_cost = (summary_input * input_price + summary_output * output_price) / 1_000_000
|
|
635
|
+
summary_time = session.llm_time - session.first_llm_time
|
|
636
|
+
|
|
637
|
+
if summary_time > 0 or session.call_count > 0:
|
|
638
|
+
stages.append({
|
|
639
|
+
"name": "Summary",
|
|
640
|
+
"model": model_name,
|
|
641
|
+
"provider": model_cfg.model_provider or "OpenRouter",
|
|
642
|
+
"description": f"生成回答 ({session.call_count} 次工具调用)",
|
|
643
|
+
"time": max(0, summary_time),
|
|
644
|
+
"usage": {"input_tokens": summary_input, "output_tokens": summary_output},
|
|
645
|
+
"cost": summary_cost,
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
return stages
|