chore: checkpoint release follow-up work

This commit is contained in:
coso
2026-06-08 01:54:08 +08:00
parent 8803d564a0
commit 05e7df9a87
9 changed files with 36 additions and 539 deletions
+8 -3
View File
@@ -247,12 +247,17 @@
- `internal/roadmap/lime-aster-codex-alignment-roadmap.md`
- `lime-rs/src/commands/agent_cmd.rs::agent_generate_title`
- `lime-rs/src/commands/persona_cmd.rs::generate_persona`
- `lime-rs/src/commands/theme_context_cmd.rs::aster_agent_theme_context_search`
这份历史档案与专用命令仍可保留各自职责,但不再承担 Query Loop 唯一事实源职责。
这三条命令属于专用一次性会话能力:允许显式拼自己的临时 `SessionConfig`,但不能参与 submit turn、runtime queue 或 evidence 真相定义。
这两条命令属于专用一次性会话能力:允许显式拼自己的临时 `SessionConfig`,但不能参与 submit turn、runtime queue 或 evidence 真相定义。
它们允许为本地 auxiliary session 附带最小 `lime_runtime` metadata,用于记录 `task_profile / routing_decision / cost_state` 一类辅助任务分类事实;必要时也可以把该 auxiliary session 的 `execution_runtime` 诊断快照回传到命令结果,但这份快照只服务该一次性会话自己的诊断与可观测性,不进入 current Query Loop 的 thread / turn 真相。
当前命令层允许保留的原始执行面只剩这 4 处:`action_runtime` 属于 current 恢复链,`agent_generate_title`、`persona_cmd` 与 `theme_context_cmd` 属于受控 compat 一次性命令。
当前命令层允许保留的原始执行面只剩这 3 处:`action_runtime` 属于 current 恢复链,`agent_generate_title` 与 `persona_cmd` 属于受控 compat 一次性命令。旧 `aster_agent_theme_context_search` 已下线,主题上下文搜索 current 事实源是 `src/lib/api/themeContextSearch.ts -> App Server agentSession/start + agentSession/turn/start + agentSession/read`。
### `dead`
- `lime-rs/src/commands/theme_context_cmd.rs::aster_agent_theme_context_search`
旧主题上下文搜索 Tauri 命令不得重新注册到 legacy desktop facade;如果后续继续增强主题上下文搜索,只能扩展 App Server current session turn 网关与 RuntimeCore/backend。
### `deprecated`
@@ -1601,8 +1601,9 @@ git diff --check
585. P3.274 live WebSearch/WebFetch 输出证据门禁:只读子代理指出工具 `status=completed` 即算通过仍可能弱化“真实 WebFetch 打开页面并确认事实”的证据。本轮只认领 `scripts/lib/claw-chat-live-web-tool-evidence.mjs`、`scripts/lib/claw-chat-live-web-tool-evidence.test.mjs`、`scripts/claw-chat-ready-streaming-smoke.mjs`、`scripts/lib/live-provider-smoke-gate.test.mjs` 与本计划 / 矩阵记录;helper 现在为 required tools 计算 `outputPresent`、`allRequiredOutputPresent`、`allRequiredOutputPresentForTurn`,行为测试覆盖完成态但空输出的 `WebFetch` 不满足 output present,live smoke verdict 新增 `liveWebRequiredToolOutputsPresent`。该刀不运行真实 Provider;完整整体目标仍需显式授权后跑联网 E2E。
586. P3.275 Electron release/updater 领域脚本继续收缩:继续沿 Forge-only release/updater 与 `scripts/` 根目录治理目标推进,本轮只认领 Electron release/updater helper、release workflow guard、scripts governance baseline、package / quality / docs 引用与本计划记录,不碰 Rust/App Server/Agent Chat 并行写集。已把 `prepare-github-release-assets.mjs`、`plan-r2-release-cleanup.mjs`、`release-updater-manifest.test.mjs` 迁入 `scripts/electron/prepare-github-release-assets.mjs`、`scripts/electron/r2-release-cleanup-plan.mjs`、`scripts/electron/release-updater-assets.test.mjs`;`release-updater-manifest.mjs` 已按 `dead` 删除,不再作为 current 或 compat 入口。`.github/workflows/release.yml`、`package.json`、Electron current guards、contract / quality guards、`scripts/README.md` 与 `internal/roadmap/appserver/release-updater.md` 均已同步新路径;旧 `scripts/(prepare-github-release-assets|plan-r2-release-cleanup|release-updater-manifest)` current 路径扫描无命中。`scripts/` 根目录基线从 P3.270 的 `rootFiles=105` 继续收缩为 `rootFiles=94 retired=0 untrackedNew=0`。
587. P3.275 验证 / 边界:修复 `scripts/electron/release-workflow-guard.mjs` 中 R2 cleanup step 名称与 `.github/workflows/release.yml` 的 `Clean old updater assets from Cloudflare R2` 对齐后,定向验证通过:`npm run governance:scripts` 输出 `rootFiles=94 retired=0 untrackedNew=0`;`npm run governance:electron-release-workflow` 通过;`npx vitest run "scripts/electron/release-updater-assets.test.mjs" "scripts/electron/release-workflow-guard.test.mjs" "scripts/electron/current-docs-guard.test.mjs" --silent=passed-only --disableConsoleIntercept` 通过 3 文件 23 项;`node --check` 覆盖 release/updater 迁移脚本与 release workflow guard 通过。该刀只治理 release/updater 脚本事实源和回流守卫,不声称 Windows Squirrel runner、macOS DMG/sign/notarize GitHub Actions 或 live Provider WebSearch/WebFetch E2E 已完成。
588. P3.276 scripts 根目录 release bucket 清零:继续按用户“一次性治理掉旧的”要求收掉 P3.275 后剩余的唯一 release bucket 根入口。本轮把 `scripts/knowledge-release-scope-report.mjs` 迁入 `scripts/knowledge/release-scope-report.mjs`,`package.json#knowledge:release-scope-report`、`scripts/README.md`、Knowledge PRD / completion audit / exec plan 引用同步到领域目录;脚本自身的 Knowledge 范围匹配补充 `scripts/knowledge/`,避免迁移后把自己的领域脚本误判为 unknown。`scripts/script-root-governance-baseline.json` 删除旧根入口,`npm run governance:scripts` 输出 `rootFiles=93 retired=0 untrackedNew=0`,根目录 bucket 只剩 `governance=4 / misc=28 / quality=31 / smoke=30`,`release` bucket 已清零。旧 `scripts/knowledge-release-scope-report.mjs` 归类为 `dead` 路径,只保留历史 evidence JSON 中的旧审计记录,不作为 current 入口。
588. P3.276 Claw live smoke 证据边界显式化:继续沿 live Provider 授权前防误判主线推进,本轮只认领 `scripts/claw-chat-ready-streaming-smoke.mjs`、`scripts/lib/live-provider-smoke-gate.test.mjs`、本计划与前端矩阵记录,不触碰 `lime-rs/crates/app-server/src/local_data_source.rs`、`runtime.rs`、`processor.rs`、`runtime_backend.rs`、`electron/appServerHost.ts` 或 Agent Chat runtime 写集。`scripts/claw-chat-ready-streaming-smoke.mjs` 的 summary 新增 `e2eBoundary`,固定 runner 为 `playwright-chromium`、browser automation 为 `chromium.launchPersistentContext`、current bridge 为 `DevBridge transport=electron-host`,证明路径写明为 `Chromium GUI -> DevBridge -> Electron Desktop Host IPC -> App Server JSON-RPC -> RuntimeCore/backend`,并显式标注 `electronRendererIpcE2e=false` / `electronLaunch=false`;`scripts/lib/live-provider-smoke-gate.test.mjs` 同步静态守卫这些字段,防止后续把 DevBridge(electron-host transport) live smoke 误称为 Playwright `_electron.launch()` renderer IPC E2E。该刀不运行真实 Provider、不新增 App Server method、不新增 mock fallback;真实 Electron renderer IPC 证据仍应由独立 Electron smoke / fixture 承接。
589. P3.276 验证 / 边界:本刀最低验证目标是 `node --check "scripts/claw-chat-ready-streaming-smoke.mjs"`、`npx vitest run "scripts/lib/live-provider-smoke-gate.test.mjs" --silent=passed-only --disableConsoleIntercept`、相关文件 Prettier / `git diff --check` 与未授权 gate `npm run smoke:claw-chat-ready-streaming -- --timeout-ms 30000`。完整整体目标仍未完成:必须在用户明确授权真实模型 / 联网 Provider 后运行 `npm run smoke:claw-chat-ready-streaming -- --allow-live-provider --timeout-ms 180000`,并按真实失败修复 provider、WebSearch/WebFetch tool runtime、streaming event 和 `agentSession/event -> agentSession/read` 回流问题;不能用 mock、external fixture 或 DevBridge 口径描述替代 live E2E。
590. P3.277 App Server cancel 语义对齐 Codex:针对“等这么长时间对么 / 是否走偏”,复核 `/Users/coso/Documents/dev/rust/codex/codex-rs/core/src/tasks/mod.rs` 后确认 Codex interrupt 只给运行任务约 `100ms` 优雅退出窗口,随后以 `TurnAborted` 作为一等终态;App Server 侧不能把 `agentSession/turn/cancel` JSON-RPC 响应绑在 external backend / live provider cleanup 上。本轮只认领 `lime-rs/crates/app-server/src/runtime.rs`、`scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs`、`.codex/skills/lime-product-e2e-loop/references/product-e2e-loop.md` 与本计划记录,不碰 `lime-rs/crates/app-server/src/local_data_source.rs`,不新增生产 mock fallback,不重新接 Tauri / legacy command。`RuntimeCore::cancel_turn(...)` 现在先通过 current read model 写入 `turn.canceled` 并返回 `AgentSessionTurnCancelResponse`,再后台 best-effort 调用 backend `cancel_turn(...)`;迟到的 `message.delta` / `turn.final_done` / `turn.completed` 不再覆盖已进入 `Completed / Failed / Canceled` 的终态 turn,避免“点停止后又变 completed / 又继续输出”。这保持事实源仍在 App Server JSON-RPC / RuntimeCore,Electron 只负责 IPC / sidecar,backend cancel 仍必须 eventually 可观测。
591. P3.277 验证 / Electron fixture:新增 Rust 回归 `cancel_turn_returns_canceled_without_waiting_for_backend_cancel` 和 `canceled_turn_ignores_late_runtime_events`,构造 backend cancel 永不返回与 cancel 后迟到事件,证明 App Server current read model 能快速进入 `canceled` 且不被迟到完成事件污染。验证通过:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server cancel_turn --lib`,2 项;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server canceled_turn_ignores_late_runtime_events --lib`,1 项;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server final_done_runtime_event_marks_turn_completed --lib`;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server append_external_runtime_events_keeps_sequence_and_turn_scope --lib`;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server json_lines_loop_streams_external_backend_events_before_turn_response --lib`。前端 / 契约验证通过:`npx vitest run "src/components/agent/chat/hooks/agentStreamFlowControl.test.ts" "src/lib/api/agentRuntime/threadClient.test.ts" --silent=passed-only --disableConsoleIntercept`,2 文件 27 项;`node "scripts/check-app-server-client-contract.mjs"`,207 项。真实 Electron fixture:`npm run smoke:claw-chat-current-fixture -- --scenario cancel --timeout-ms 180000` 约 6 秒通过,summary session `claw-chat-current-1780854193018-58393`,证明 GUI 点击停止、`agentSession/turn/cancel`、read model `canceled`、输入框恢复和 backend ledger `turnCancel` eventually 到达;随后 `npm run smoke:claw-chat-current-fixture -- --timeout-ms 180000` 约 5 秒通过,summary session `claw-chat-current-1780854208226-59427`,证明“整理今天的国际新闻”完成态仍走 current App Server JSON-RPC + external fixture backend,且不调用正式模型后端。剩余完整目标仍是显式授权后的 live Provider streaming / WebSearch-WebFetch 实际联网 E2E。
592. P3.278 scripts 根目录 release bucket 清零:继续按用户“一次性治理掉旧的”要求收掉 P3.275 后剩余的唯一 release bucket 根入口。本轮把 `scripts/knowledge-release-scope-report.mjs` 迁入 `scripts/knowledge/release-scope-report.mjs`,`package.json#knowledge:release-scope-report`、`scripts/README.md`、Knowledge PRD / completion audit / exec plan 引用同步到领域目录;脚本自身的 Knowledge 范围匹配补充 `scripts/knowledge/`,避免迁移后把自己的领域脚本误判为 unknown。`scripts/script-root-governance-baseline.json` 删除旧根入口,`npm run governance:scripts` 输出 `rootFiles=93 retired=0 untrackedNew=0`,根目录 bucket 只剩 `governance=4 / misc=28 / quality=31 / smoke=30`,`release` bucket 已清零。旧 `scripts/knowledge-release-scope-report.mjs` 归类为 `dead` 路径,只保留历史 evidence JSON 中的旧审计记录,不作为 current 入口。
593. P3.279 Codex cancel 语义复核 / 不再长等:收到“等这么长时间对么 / 参考 codex / 已经走偏”的反馈后,本轮只读对照 `/Users/coso/Documents/dev/rust/codex/codex-rs/core/src/tasks/mod.rs` 与 `app-server/src/bespoke_event_handling.rs`,确认 Codex interrupt 路径通过 cancellation token 取消运行任务,只给 `GRACEFULL_INTERRUPTION_TIMEOUT_MS=100` 的短暂优雅窗口,然后 abort task 并发出 `TurnAborted`;App Server 侧收到 `TurnAborted` 会回复 pending interrupt 并把 turn 投影为 interrupted/completed 状态。这证明 Lime 不能用长 timeout、等待 backend cleanup 或合成 `final_done` 作为停止交付标准。复核当前 Lime 主链后确认:`RuntimeCore::cancel_turn(...)` 已先写入 `turn.canceled` read model,再后台 best-effort 调 backend cancel;前端 `stopActiveAgentStream(...)` 已先解除 active stream、恢复输入框并异步 cancel + refresh;`threadClient` 会把 cancel response notifications 路由回当前 stream event。复测通过:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server cancel_turn --lib`,2 项;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server canceled_turn_ignores_late_runtime_events --lib`,1 项;`npx vitest run "src/components/agent/chat/hooks/agentStreamFlowControl.test.ts" "src/lib/api/agentRuntime/threadClient.test.ts" --silent=passed-only --disableConsoleIntercept`,2 文件 27 项;`node "scripts/check-app-server-client-contract.mjs"`,207 项;`npm run smoke:claw-chat-current-fixture -- --scenario cancel --timeout-ms 180000` 约 5.7 秒通过,session `claw-chat-current-1780854725258-94095`;`npm run smoke:claw-chat-current-fixture -- --timeout-ms 180000` 约 4.4 秒通过,session `claw-chat-current-1780854745753-95800`,证明“整理今天的国际新闻”在 Electron GUI + App Server JSON-RPC + external fixture backend 中完成,且不调用正式模型后端。本轮不触碰并行修改的 `lime-rs/src/app/runner.rs`、`lime-rs/src/commands/mod.rs`、live Provider smoke 写集,也不修改 `local_data_source.rs`。
-1
View File
@@ -1352,7 +1352,6 @@ pub fn run() {
commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session,
commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action,
commands::aster_agent_cmd::tool_runtime::social_tools::social_generate_cover_image_cmd,
commands::theme_context_cmd::aster_agent_theme_context_search,
// Models config commands
commands::models_cmd::get_models_config,
commands::models_cmd::save_models_config,
@@ -8083,7 +8083,6 @@ mod tests {
"src/commands/aster_agent_cmd/action_runtime.rs",
"src/commands/agent_cmd.rs",
"src/commands/persona_cmd.rs",
"src/commands/theme_context_cmd.rs",
]
.into_iter()
.map(ToString::to_string)
-1
View File
@@ -53,7 +53,6 @@ pub mod skill_error;
pub mod skill_exec_cmd;
pub mod telegram_remote_cmd;
pub mod telemetry_cmd;
pub mod theme_context_cmd;
pub mod tray_cmd;
pub mod unified_memory_cmd;
pub mod update_cmd;
+4 -529
View File
@@ -1,530 +1,5 @@
//! 主题工作台上下文搜索命令
//! Retired legacy theme context search command.
//!
//! 为左侧栏“搜索上下文”提供真正的后端检索能力,
//! 统一复用 Aster Agent + WebSearch 策略,并返回结构化结果。
use crate::agent::{build_auxiliary_session_config, AsterAgentState, AsterAgentWrapper};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::memory_profile_prompt_service::{
merge_system_prompt_with_memory_context, MemoryPromptContext,
};
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
use crate::workspace::WorkspaceManager;
use lime_agent::{
merge_system_prompt_with_runtime_agents, resolve_request_tool_policy_with_mode,
stream_reply_with_policy, RequestToolPolicyMode,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::State;
use url::Url;
use uuid::Uuid;
const CONTEXT_SEARCH_SESSION_PREFIX: &str = "__lime_theme_context_search__";
const FALLBACK_SUMMARY_LENGTH: usize = 420;
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ThemeContextSearchMode {
Web,
Social,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ThemeContextSearchRequest {
#[serde(alias = "workspaceId")]
pub workspace_id: String,
#[serde(default, alias = "projectId")]
pub project_id: Option<String>,
#[serde(alias = "providerType", alias = "providerId")]
pub provider_type: String,
pub model: String,
pub query: String,
pub mode: ThemeContextSearchMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ThemeContextSearchCitation {
pub title: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ThemeContextSearchResponse {
pub title: String,
pub summary: String,
pub citations: Vec<ThemeContextSearchCitation>,
pub raw_response: String,
pub attempts_summary: String,
}
#[derive(Debug, Clone)]
struct ParsedThemeContextSearchPayload {
title: Option<String>,
summary: Option<String>,
citations: Vec<ThemeContextSearchCitation>,
}
fn normalize_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn build_context_search_prompt(query: &str, mode: ThemeContextSearchMode) -> String {
let social_constraint = match mode {
ThemeContextSearchMode::Social => [
"优先寻找社交媒体平台、品牌官方账号、媒体社媒账号、KOL/KOC 讨论与趋势帖相关信息。",
"如果直接社媒来源不足,可补充官方网站或媒体报道,但摘要必须保留社媒传播视角。",
"适当优先关注小红书、微博、公众号、抖音、B站、知乎等中文平台。",
]
.join("\n"),
ThemeContextSearchMode::Web => {
"优先提供最新且可信的公开网络资料,兼顾官方来源与主流媒体。".to_string()
}
};
[
"你是 Lime 的资料检索助手。",
"请先执行联网搜索,再输出整理结果。",
"你必须返回且仅返回一个 JSON 对象,不要使用 Markdown 代码块,不要输出多余说明。",
"JSON 结构如下:",
r#"{"title":"","summary":"","citations":[{"title":"","url":""}]}"#,
"字段要求:",
"1. title:12-28 字中文标题,概括本次检索主题。",
"2. summary:180-320 字中文摘要,聚合 3-5 个来源,突出时间点、关键事实、趋势或洞察。",
"3. citations:保留 3-5 条最重要来源,必须带可访问 URL。",
social_constraint.as_str(),
&format!("检索主题:{}", query.trim()),
]
.join("\n")
}
fn strip_code_fence(value: &str) -> String {
value
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim()
.to_string()
}
fn build_citation_title_from_url(url: &str) -> String {
Url::parse(url)
.ok()
.and_then(|parsed| {
parsed
.host_str()
.map(|host| host.trim_start_matches("www.").to_string())
})
.filter(|host| !host.is_empty())
.unwrap_or_else(|| "来源链接".to_string())
}
fn sanitize_url(url: &str) -> String {
url.trim_end_matches(&[',', ')', '.', ';', '!', '?'][..])
.trim()
.to_string()
}
fn parse_json_payload(raw_response: &str) -> Option<ParsedThemeContextSearchPayload> {
let trimmed = raw_response.trim();
if trimmed.is_empty() {
return None;
}
let fenced_match = regex::Regex::new(r"```(?:json)?\s*([\s\S]*?)\s*```")
.ok()
.and_then(|regex| regex.captures(trimmed))
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()));
let json_block_match = regex::Regex::new(r"\{[\s\S]*\}")
.ok()
.and_then(|regex| regex.find(trimmed))
.map(|value| value.as_str().to_string());
let mut candidates = Vec::new();
if let Some(value) = fenced_match {
candidates.push(value);
}
if let Some(value) = json_block_match {
candidates.push(value);
}
candidates.push(trimmed.to_string());
for candidate in candidates {
let normalized = strip_code_fence(&candidate);
let parsed = match serde_json::from_str::<serde_json::Value>(&normalized) {
Ok(value) => value,
Err(_) => continue,
};
let citations_raw = parsed
.get("citations")
.and_then(serde_json::Value::as_array)
.or_else(|| parsed.get("sources").and_then(serde_json::Value::as_array));
let citations = citations_raw
.into_iter()
.flatten()
.filter_map(|item| {
let url = item
.get("url")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let title = item
.get("title")
.and_then(serde_json::Value::as_str)
.or_else(|| item.get("name").and_then(serde_json::Value::as_str))
.map(normalize_whitespace)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| build_citation_title_from_url(url));
Some(ThemeContextSearchCitation {
title,
url: url.to_string(),
})
})
.take(5)
.collect::<Vec<_>>();
let title = parsed
.get("title")
.and_then(serde_json::Value::as_str)
.map(normalize_whitespace)
.filter(|value| !value.is_empty());
let summary = parsed
.get("summary")
.and_then(serde_json::Value::as_str)
.or_else(|| parsed.get("content").and_then(serde_json::Value::as_str))
.map(normalize_whitespace)
.filter(|value| !value.is_empty());
return Some(ParsedThemeContextSearchPayload {
title,
summary,
citations,
});
}
None
}
fn extract_citations_from_text(raw_response: &str) -> Vec<ThemeContextSearchCitation> {
let mut citations = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Ok(markdown_regex) = regex::Regex::new(r"\[([^\]]+)\]\((https?://[^\s)]+)\)") {
for captures in markdown_regex.captures_iter(raw_response) {
let url = captures
.get(2)
.map(|value| sanitize_url(value.as_str()))
.unwrap_or_default();
if url.is_empty() || !seen.insert(url.clone()) {
continue;
}
let title = captures
.get(1)
.map(|value| normalize_whitespace(value.as_str()))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| build_citation_title_from_url(&url));
citations.push(ThemeContextSearchCitation { title, url });
if citations.len() >= 5 {
return citations;
}
}
}
if let Ok(url_regex) = regex::Regex::new(r"https?://[^\s)\]]+") {
for captures in url_regex.find_iter(raw_response) {
let url = sanitize_url(captures.as_str());
if url.is_empty() || !seen.insert(url.clone()) {
continue;
}
citations.push(ThemeContextSearchCitation {
title: build_citation_title_from_url(&url),
url,
});
if citations.len() >= 5 {
break;
}
}
}
citations
}
fn build_fallback_summary(raw_response: &str) -> String {
let without_citations = regex::Regex::new(r#""citations"\s*:\s*\[[\s\S]*?\]"#)
.ok()
.map(|regex| {
regex
.replace_all(&strip_code_fence(raw_response), "")
.to_string()
})
.unwrap_or_else(|| strip_code_fence(raw_response));
let normalized =
normalize_whitespace(&without_citations.replace(&['{', '}', '[', ']', '"'][..], " "));
if normalized.is_empty() {
return "暂无可用摘要,请重新尝试检索。".to_string();
}
if normalized.chars().count() <= FALLBACK_SUMMARY_LENGTH {
return normalized;
}
let mut summary = normalized
.chars()
.take(FALLBACK_SUMMARY_LENGTH)
.collect::<String>();
summary.push_str("...");
summary
}
fn build_fallback_title(query: &str, mode: ThemeContextSearchMode) -> String {
let suffix = match mode {
ThemeContextSearchMode::Social => "社媒搜索上下文",
ThemeContextSearchMode::Web => "网络搜索上下文",
};
format!("{} · {}", query.trim(), suffix)
}
fn normalize_search_result(
raw_response: &str,
query: &str,
mode: ThemeContextSearchMode,
attempts_summary: String,
) -> ThemeContextSearchResponse {
let parsed = parse_json_payload(raw_response);
let citations = parsed
.as_ref()
.filter(|payload| !payload.citations.is_empty())
.map(|payload| payload.citations.clone())
.unwrap_or_else(|| extract_citations_from_text(raw_response));
ThemeContextSearchResponse {
title: parsed
.as_ref()
.and_then(|payload| payload.title.clone())
.unwrap_or_else(|| build_fallback_title(query, mode)),
summary: parsed
.as_ref()
.and_then(|payload| payload.summary.clone())
.unwrap_or_else(|| build_fallback_summary(raw_response)),
citations,
raw_response: raw_response.to_string(),
attempts_summary,
}
}
#[tauri::command]
pub async fn aster_agent_theme_context_search(
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
config_manager: State<'_, GlobalConfigManagerState>,
request: ThemeContextSearchRequest,
) -> Result<ThemeContextSearchResponse, String> {
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
let provider_type = request.provider_type.trim().to_string();
if provider_type.is_empty() {
return Err("当前未选择可用模型,无法执行联网搜索".to_string());
}
let model = request.model.trim().to_string();
if model.is_empty() {
return Err("当前未选择可用模型,无法执行联网搜索".to_string());
}
let query = request.query.trim().to_string();
if query.is_empty() {
return Err("搜索词不能为空".to_string());
}
if !state.is_initialized().await {
state.init_agent_with_db(&db).await?;
}
let manager = WorkspaceManager::new(db.inner().clone());
let workspace = manager
.get(&workspace_id)
.map_err(|error| format!("读取 workspace 失败: {error}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
let workspace_root = ensured.root_path.to_string_lossy().to_string();
let runtime_config = config_manager.config();
apply_web_search_runtime_env(&runtime_config);
let project_prompt = request
.project_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|project_id| {
match AsterAgentState::build_project_system_prompt(&db, project_id) {
Ok(prompt) => Some(prompt),
Err(error) => {
tracing::warn!(
"[ThemeContextSearch] 加载项目上下文失败,降级为基础搜索提示词: {}",
error
);
None
}
}
});
let request_tool_policy = resolve_request_tool_policy_with_mode(
Some(true),
Some(RequestToolPolicyMode::Required),
false,
);
let system_prompt = lime_agent::merge_system_prompt_with_request_tool_policy(
merge_system_prompt_with_web_search(
merge_system_prompt_with_memory_context(
merge_system_prompt_with_runtime_agents(
project_prompt,
Some(Path::new(&workspace_root)),
),
&runtime_config,
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
),
&runtime_config,
),
&request_tool_policy,
);
let session_id = format!("{}-{}", CONTEXT_SEARCH_SESSION_PREFIX, Uuid::new_v4());
state
.configure_provider_from_pool(&db, &provider_type, &model, &session_id, None)
.await?;
let cancel_token = state.create_cancel_token(&session_id).await;
let execution_result = {
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard
.as_ref()
.ok_or_else(|| "Agent not initialized".to_string())?;
stream_reply_with_policy(
agent,
&build_context_search_prompt(&query, request.mode),
Some(Path::new(&workspace_root)),
build_auxiliary_session_config(&session_id, system_prompt, false),
Some(cancel_token.clone()),
&request_tool_policy,
|_| {},
)
.await
};
state.remove_cancel_token(&session_id).await;
if let Err(error) = AsterAgentWrapper::delete_session(&db, &session_id).await {
tracing::warn!(
"[ThemeContextSearch] 删除临时会话失败: session={}, error={}",
session_id,
error
);
}
let execution = execution_result.map_err(|error| error.message)?;
let raw_response = execution.text_output.trim().to_string();
if raw_response.is_empty() {
return Err("上下文搜索未返回可用内容,请重试".to_string());
}
Ok(normalize_search_result(
&raw_response,
&query,
request.mode,
execution.attempts_summary,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_parse_json_result() {
let result = normalize_search_result(
r#"{"title":"智能体市场观察","summary":"市场讨论聚焦推理成本、工作流平台和企业落地节奏。","citations":[{"title":"官方博客","url":"https://example.com/blog"}]}"#,
"智能体市场 2026",
ThemeContextSearchMode::Web,
"WebSearch#1:success".to_string(),
);
assert_eq!(result.title, "智能体市场观察");
assert!(result.summary.contains("推理成本"));
assert_eq!(
result.citations,
vec![ThemeContextSearchCitation {
title: "官方博客".to_string(),
url: "https://example.com/blog".to_string(),
}]
);
assert_eq!(result.attempts_summary, "WebSearch#1:success");
}
#[test]
fn should_fallback_to_text_and_links_when_json_invalid() {
let result = normalize_search_result(
[
"2026 年社交媒体讨论聚焦 Agent 产品的真实 ROI。",
"参考链接:",
"[小红书热议](https://example.com/xhs)",
"https://example.com/weibo",
]
.join("\n")
.as_str(),
"Agent 社媒讨论",
ThemeContextSearchMode::Social,
"WebSearch#1:success".to_string(),
);
assert!(result.title.contains("Agent 社媒讨论"));
assert!(result.summary.contains("真实 ROI"));
assert_eq!(
result.citations,
vec![
ThemeContextSearchCitation {
title: "小红书热议".to_string(),
url: "https://example.com/xhs".to_string(),
},
ThemeContextSearchCitation {
title: "example.com".to_string(),
url: "https://example.com/weibo".to_string(),
},
]
);
}
#[test]
fn should_deserialize_theme_context_request_with_aliases() {
let request: ThemeContextSearchRequest = serde_json::from_str(
r#"{
"workspaceId": "workspace-test",
"projectId": "project-test",
"providerType": "openai",
"model": "gpt-4.1",
"query": "AI Agent 最新动态",
"mode": "web"
}"#,
)
.expect("request should deserialize");
assert_eq!(request.workspace_id, "workspace-test");
assert_eq!(request.project_id.as_deref(), Some("project-test"));
assert_eq!(request.provider_type, "openai");
assert_eq!(request.model, "gpt-4.1");
assert_eq!(request.mode, ThemeContextSearchMode::Web);
}
}
//! The active path is the App Server current session turn chain:
//! `src/lib/api/themeContextSearch.ts -> agentSession/start -> agentSession/turn/start`.
//! Do not re-register this module in the legacy desktop facade.
+3 -3
View File
@@ -2322,9 +2322,9 @@ async function main() {
summary.liveWebTurn = liveWebCompleted.turn;
summary.liveWebTurnStatus = liveWebCompleted.turn?.status || null;
summary.liveWebToolEvidence = liveWebCompleted.toolEvidence;
if (!summary.liveWebToolEvidence?.allRequiredCompletedForTurn) {
if (!summary.liveWebToolEvidence?.allRequiredOutputPresentForTurn) {
const settledToolEvidence = await waitForCondition(
"等待 read model 出现 live WebSearch/WebFetch 工具事实",
"等待 read model 出现 live WebSearch/WebFetch 工具输出事实",
async () => {
const session = await readAppServerSession(
options,
@@ -2338,7 +2338,7 @@ async function main() {
passed: false,
latest: toolEvidence,
};
return toolEvidence.allRequiredCompletedForTurn
return toolEvidence.allRequiredOutputPresentForTurn
? { session, toolEvidence }
: null;
},
@@ -164,6 +164,10 @@ describe("live-provider-smoke-gate", () => {
expect(content).toContain("liveWebToolEvidenceFromSession");
expect(content).toContain("turnId: liveWebTurnId");
expect(content).toContain("allRequiredCompletedForTurn");
expect(content).toContain("allRequiredOutputPresentForTurn");
expect(content).toContain(
"等待 read model 出现 live WebSearch/WebFetch 工具输出事实",
);
expect(content).toContain("summary.assertions.liveWebSearchCompleted");
expect(content).toContain("summary.assertions.liveWebFetchCompleted");
expect(content).toContain(
@@ -4053,6 +4053,21 @@
],
"allowedPaths": []
},
{
"id": "rust-theme-context-search-legacy-command",
"classification": "dead",
"description": "旧主题上下文搜索 Tauri 命令已下线;current 事实源是 App Server agentSession current turn 网关",
"patterns": [
"pub mod theme_context_cmd;",
"commands::theme_context_cmd::",
"aster_agent_theme_context_search"
],
"includePathPrefixes": [
"lime-rs/src",
"lime-rs/crates"
],
"allowedPaths": []
},
{
"id": "rust-general-chat-dao",
"classification": "dead-candidate",