mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
chore: checkpoint release follow-up work
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user