From 2069f791980d80dc0bb06ba0bfed00716a663fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 16 Jun 2026 20:19:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20df-ai-core=20trait?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E6=8B=86crate+=E5=AF=BC=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E9=A1=B9=E7=9B=AE=E6=89=B9=E9=87=8F=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 14 + crates/df-ai-core/Cargo.toml | 11 + crates/df-ai-core/src/lib.rs | 10 + crates/df-ai-core/src/provider.rs | 235 +++++++++++++ crates/df-ai/Cargo.toml | 1 + crates/df-ai/src/lib.rs | 3 + crates/df-ai/src/provider.rs | 242 +------------- crates/df-ideas/Cargo.toml | 2 + crates/df-ideas/src/adversarial.rs | 117 +++++-- crates/df-project/src/scan.rs | 521 ++++++++++++++++++++++++++++- docs/todo.md | 4 +- docs/待审查.md | 28 +- src-tauri/src/commands/idea.rs | 39 ++- src-tauri/src/commands/project.rs | 295 +++++++++++++--- src-tauri/src/lib.rs | 2 + src/api/project.ts | 49 +++ src/i18n/en/projects.ts | 20 ++ src/i18n/zh-CN/projects.ts | 20 ++ src/views/Projects.vue | 216 +++++++++++- 19 files changed, 1518 insertions(+), 311 deletions(-) create mode 100644 crates/df-ai-core/Cargo.toml create mode 100644 crates/df-ai-core/src/lib.rs create mode 100644 crates/df-ai-core/src/provider.rs diff --git a/Cargo.lock b/Cargo.lock index cac6fd1..50db673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,6 +750,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "df-ai-core", "df-core", "eventsource-stream", "futures", @@ -760,6 +761,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "df-ai-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "serde", + "serde_json", +] + [[package]] name = "df-core" version = "0.1.0" @@ -789,7 +801,9 @@ name = "df-ideas" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "chrono", + "df-ai-core", "df-core", "serde", "serde_json", diff --git a/crates/df-ai-core/Cargo.toml b/crates/df-ai-core/Cargo.toml new file mode 100644 index 0000000..288e021 --- /dev/null +++ b/crates/df-ai-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "df-ai-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +futures = "0.3" diff --git a/crates/df-ai-core/src/lib.rs b/crates/df-ai-core/src/lib.rs new file mode 100644 index 0000000..edf687d --- /dev/null +++ b/crates/df-ai-core/src/lib.rs @@ -0,0 +1,10 @@ +//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖) +//! +//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑 +//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export +//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate +//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。 + +pub mod provider; + +pub use provider::*; diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs new file mode 100644 index 0000000..84e70ba --- /dev/null +++ b/crates/df-ai-core/src/provider.rs @@ -0,0 +1,235 @@ +//! LLM Provider trait — 统一的 LLM 调用抽象 +//! +//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式), +//! 含 function calling / tool use 能力。 +//! +//! 本文件仅含 trait + 数据结构定义(零 IO)。HTTP impl(OpenAICompatProvider / +//! AnthropicCompatProvider)+ 业务逻辑(ContextManager / AiToolRegistry / +//! build_provider 工厂)留在 df-ai crate。 + +use std::pin::Pin; + +use async_trait::async_trait; +use futures::Stream; +use serde::{Deserialize, Serialize}; + +// ============================================================ +// 核心数据结构 +// ============================================================ + +/// LLM 调用请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionRequest { + /// 模型名称 + pub model: String, + /// 提示消息列表 + pub messages: Vec, + /// 温度(0.0 ~ 2.0) + pub temperature: Option, + /// 最大生成 token 数 + pub max_tokens: Option, + /// 是否流式输出 + pub stream: bool, + /// 可调用的工具定义 + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"} + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// 聊天消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: MessageRole, + pub content: String, + /// 工具调用 ID(role=Tool 时必填) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + /// AI 发起的工具调用列表(role=Assistant 时可能有) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + /// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 消息状态(UX-09 编辑重生成):None/"active" 正常可见; + /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) + /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl ChatMessage { + pub fn system(content: impl Into) -> Self { + Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } + } + pub fn user(content: impl Into) -> Self { + Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } + } + pub fn assistant(content: impl Into) -> Self { + Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } + } + pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> Self { + Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None } + } + pub fn tool_result(call_id: impl Into, content: impl Into) -> Self { + Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None } + } + + /// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。 + pub fn is_active(&self) -> bool { + !matches!(self.status.as_deref(), Some("truncated")) + } +} + +/// 消息角色 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + System, + User, + Assistant, + Tool, +} + +/// 工具定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDefinition { + #[serde(rename = "type")] + pub tool_type: String, + pub function: ToolFunction, +} + +impl ToolDefinition { + pub fn function(name: impl Into, description: impl Into, parameters: serde_json::Value) -> Self { + Self { + tool_type: "function".into(), + function: ToolFunction { name: name.into(), description: description.into(), parameters }, + } + } +} + +/// 函数定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunction { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +/// 工具调用(AI 发起) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: ToolCallFunction, +} + +impl ToolCall { + pub fn new(id: impl Into, name: impl Into, arguments: impl Into) -> Self { + Self { + id: id.into(), + call_type: "function".into(), + function: ToolCallFunction { name: name.into(), arguments: arguments.into() }, + } + } +} + +/// 工具调用函数部分 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, +} + +/// LLM 调用响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionResponse { + /// 生成的文本 + pub text: String, + /// 使用的模型 + pub model: String, + /// 消耗的 token 数 + pub usage: TokenUsage, + /// AI 发起的工具调用(如有) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +/// Token 用量 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +/// 流式输出的 chunk +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamChunk { + /// 增量文本 + pub delta: String, + /// 是否结束 + pub finished: bool, + /// 工具调用增量(如有) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + /// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。 + /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 + #[serde(skip)] + pub error: Option, +} + +/// 工具调用增量(流式中的片段) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallDelta { + /// 索引 + pub index: u32, + /// 工具调用 ID(仅第一个 chunk 有) + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// 函数名片段 + #[serde(skip_serializing_if = "Option::is_none")] + pub function_name: Option, + /// 函数参数片段 + #[serde(skip_serializing_if = "Option::is_none")] + pub function_arguments: Option, +} + +/// 异步流类型别名 +pub type StreamResult = Pin> + Send>>; + +/// LLM Provider trait +#[async_trait] +pub trait LlmProvider: Send + Sync { + /// 同步调用 + async fn complete(&self, request: CompletionRequest) -> anyhow::Result; + + /// 流式调用(返回异步流) + async fn stream( + &self, + request: CompletionRequest, + ) -> anyhow::Result; + + /// 文本嵌入:批量文本 → 语义向量(供知识库向量检索) + /// + /// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings); + /// Anthropic 无 embedding API,保持默认。 + async fn embed(&self, _model: &str, _texts: Vec) -> anyhow::Result>> { + anyhow::bail!("该 Provider 不支持 embedding({})", self.name()) + } + + /// Provider 名称 + fn name(&self) -> &str; + + /// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。 + /// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印 + /// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。 + fn endpoint(&self) -> String { + self.name().to_string() + } +} diff --git a/crates/df-ai/Cargo.toml b/crates/df-ai/Cargo.toml index 08502f0..7361586 100644 --- a/crates/df-ai/Cargo.toml +++ b/crates/df-ai/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] df-core = { path = "../df-core" } +df-ai-core = { path = "../df-ai-core" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index fb4c377..86492da 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -13,6 +13,9 @@ pub mod retry; use provider::LlmProvider; +// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。 +pub use df_ai_core; + /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match) /// /// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方), diff --git a/crates/df-ai/src/provider.rs b/crates/df-ai/src/provider.rs index 0e86252..d3794b2 100644 --- a/crates/df-ai/src/provider.rs +++ b/crates/df-ai/src/provider.rs @@ -1,231 +1,15 @@ -//! LLM Provider trait — 统一的 LLM 调用抽象 +//! LLM Provider trait + 数据结构 re-export //! -//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式), -//! 含 function calling / tool use 能力。 +//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方 +//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。 +//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变 +//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools) +//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider` +//! 等引用全部透明继续可用(编译期验证)。 +//! +//! 留在 df-ai 的部分: +//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs) +//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl) +//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑) -use std::pin::Pin; - -use async_trait::async_trait; -use futures::Stream; -use serde::{Deserialize, Serialize}; - -// ============================================================ -// 核心数据结构 -// ============================================================ - -/// LLM 调用请求 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompletionRequest { - /// 模型名称 - pub model: String, - /// 提示消息列表 - pub messages: Vec, - /// 温度(0.0 ~ 2.0) - pub temperature: Option, - /// 最大生成 token 数 - pub max_tokens: Option, - /// 是否流式输出 - pub stream: bool, - /// 可调用的工具定义 - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - /// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"} - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, -} - -/// 聊天消息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatMessage { - pub role: MessageRole, - pub content: String, - /// 工具调用 ID(role=Tool 时必填) - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - /// AI 发起的工具调用列表(role=Assistant 时可能有) - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - /// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - /// 消息状态(UX-09 编辑重生成):None/"active" 正常可见; - /// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤) - /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, -} - -impl ChatMessage { - pub fn system(content: impl Into) -> Self { - Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } - } - pub fn user(content: impl Into) -> Self { - Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } - } - pub fn assistant(content: impl Into) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None } - } - pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None } - } - pub fn tool_result(call_id: impl Into, content: impl Into) -> Self { - Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None } - } - - /// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。 - pub fn is_active(&self) -> bool { - !matches!(self.status.as_deref(), Some("truncated")) - } -} - -/// 消息角色 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum MessageRole { - System, - User, - Assistant, - Tool, -} - -/// 工具定义 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - #[serde(rename = "type")] - pub tool_type: String, - pub function: ToolFunction, -} - -impl ToolDefinition { - pub fn function(name: impl Into, description: impl Into, parameters: serde_json::Value) -> Self { - Self { - tool_type: "function".into(), - function: ToolFunction { name: name.into(), description: description.into(), parameters }, - } - } -} - -/// 函数定义 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolFunction { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -/// 工具调用(AI 发起) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - #[serde(rename = "type")] - pub call_type: String, - pub function: ToolCallFunction, -} - -impl ToolCall { - pub fn new(id: impl Into, name: impl Into, arguments: impl Into) -> Self { - Self { - id: id.into(), - call_type: "function".into(), - function: ToolCallFunction { name: name.into(), arguments: arguments.into() }, - } - } -} - -/// 工具调用函数部分 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallFunction { - pub name: String, - pub arguments: String, -} - -/// LLM 调用响应 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompletionResponse { - /// 生成的文本 - pub text: String, - /// 使用的模型 - pub model: String, - /// 消耗的 token 数 - pub usage: TokenUsage, - /// AI 发起的工具调用(如有) - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -/// Token 用量 -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TokenUsage { - pub prompt_tokens: u32, - pub completion_tokens: u32, - pub total_tokens: u32, -} - -/// 流式输出的 chunk -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamChunk { - /// 增量文本 - pub delta: String, - /// 是否结束 - pub finished: bool, - /// 工具调用增量(如有) - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, - /// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件) - #[serde(skip_serializing_if = "Option::is_none")] - pub usage: Option, - /// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。 - /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 - #[serde(skip)] - pub error: Option, -} - -/// 工具调用增量(流式中的片段) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallDelta { - /// 索引 - pub index: u32, - /// 工具调用 ID(仅第一个 chunk 有) - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - /// 函数名片段 - #[serde(skip_serializing_if = "Option::is_none")] - pub function_name: Option, - /// 函数参数片段 - #[serde(skip_serializing_if = "Option::is_none")] - pub function_arguments: Option, -} - -/// 异步流类型别名 -pub type StreamResult = Pin> + Send>>; - -/// LLM Provider trait -#[async_trait] -pub trait LlmProvider: Send + Sync { - /// 同步调用 - async fn complete(&self, request: CompletionRequest) -> anyhow::Result; - - /// 流式调用(返回异步流) - async fn stream( - &self, - request: CompletionRequest, - ) -> anyhow::Result; - - /// 文本嵌入:批量文本 → 语义向量(供知识库向量检索) - /// - /// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings); - /// Anthropic 无 embedding API,保持默认。 - async fn embed(&self, _model: &str, _texts: Vec) -> anyhow::Result>> { - anyhow::bail!("该 Provider 不支持 embedding({})", self.name()) - } - - /// Provider 名称 - fn name(&self) -> &str; - - /// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。 - /// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印 - /// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。 - fn endpoint(&self) -> String { - self.name().to_string() - } -} +pub use df_ai_core::provider::*; diff --git a/crates/df-ideas/Cargo.toml b/crates/df-ideas/Cargo.toml index 8c0fde5..69a6828 100644 --- a/crates/df-ideas/Cargo.toml +++ b/crates/df-ideas/Cargo.toml @@ -5,9 +5,11 @@ edition = "2021" [dependencies] df-core = { path = "../df-core" } +df-ai-core = { path = "../df-ai-core" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +async-trait = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index be5bf96..d45d281 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -1,15 +1,40 @@ //! 对抗式评估系统 — 正反方辩论 + AI 分析师 //! -//! 当前为基于评分与内容信号的启发式实现(稳定、有区分度)。 -//! TODO: 接入 df-ai LlmProvider 让正反方论点由 LLM 生成,启发式降级为 fallback。 +//! 双轨实现: +//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。 +//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。 +//! +//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式, +//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。 +//! +//! LLM prompt 构造与 JSON 解析在 F-260614-03(已由本任务解锁)接入,当前 `evaluate_with_llm` +//! 返回 Err 触发降级路径——机制完整,仅缺 prompt/解析实现。 + +use std::sync::Arc; use anyhow::Result; use serde::{Deserialize, Serialize}; +use df_ai_core::provider::LlmProvider; use df_core::types::{IdeaId, Priority}; use crate::capture::Idea; use crate::scoring::IdeaScores; +/// 评估来源标记 +/// +/// `Default = Heuristic`:老数据(F-07 之前)序列化时无 evaluated_by 字段, +/// 反序列化回落启发式(与 F-07 之前行为一致)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum EvaluatedBy { + /// LLM 深度评估 + Llm, + /// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值) + #[default] + Heuristic, + /// 启发式降级(LLM 调用失败后 fallback) + HeuristicFallback, +} + /// 对抗评估结果 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdversarialEval { @@ -19,6 +44,9 @@ pub struct AdversarialEval { pub analyst: AnalystAnalysis, pub final_score: f64, pub recommendation: Recommendation, + /// 评估来源(Llm / Heuristic / HeuristicFallback),前端据此显示评估深度标签 + #[serde(default)] + pub evaluated_by: EvaluatedBy, } /// 论点(正方/反方共用同一结构) @@ -62,19 +90,64 @@ pub enum Recommendation { } /// 对抗评估引擎 -pub struct AdversarialEngine; +pub struct AdversarialEngine { + /// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。 + /// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。 + provider: Option>, +} impl AdversarialEngine { - /// 执行完整的对抗评估 - #[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用 - pub async fn evaluate(idea: &Idea) -> Result { + /// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式) + pub fn new(provider: Arc) -> Self { + Self { provider: Some(provider) } + } + + /// 纯启发式构造(无 LLM 配置时的默认模式) + pub fn heuristic() -> Self { + Self { provider: None } + } + + /// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级) + pub async fn evaluate(&self, idea: &Idea) -> Result { + match &self.provider { + Some(p) => match self.evaluate_with_llm(idea, p).await { + Ok(mut eval) => { + eval.evaluated_by = EvaluatedBy::Llm; + Ok(eval) + } + Err(e) => { + // LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回 + tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}"); + let mut eval = self.evaluate_heuristic(idea)?; + eval.evaluated_by = EvaluatedBy::HeuristicFallback; + Ok(eval) + } + }, + None => { + let mut eval = self.evaluate_heuristic(idea)?; + eval.evaluated_by = EvaluatedBy::Heuristic; + Ok(eval) + } + } + } + + /// LLM 对抗评估(注入 provider 后走此路)。 + /// + /// prompt 构造 + JSON 解析在 F-260614-03(已由本任务解锁)接入。当前返回 Err + /// 触发降级路径——降级机制与启发式评估路径完整,仅缺 LLM 调用实现。 + async fn evaluate_with_llm(&self, _idea: &Idea, _provider: &Arc) -> Result { + anyhow::bail!("LLM 对抗评估尚未实现(F-260614-03)") + } + + /// 启发式评估(基于评分与内容信号,稳定有区分度) + fn evaluate_heuristic(&self, idea: &Idea) -> Result { // 先做多维评分,作为正反方论点与置信度的依据 let scores = crate::scoring::ScoringEngine::compute_default(idea); - let positive = Self::generate_positive_argument(idea, &scores)?; - let negative = Self::generate_negative_argument(idea, &scores)?; - let analyst = Self::analyst_analysis(idea, &scores)?; - let recommendation = Self::recommendation_for(&analyst.final_assessment); + let positive = self.generate_positive_argument(idea, &scores)?; + let negative = self.generate_negative_argument(idea, &scores)?; + let analyst = self.analyst_analysis(idea, &scores)?; + let recommendation = self.recommendation_for(&analyst.final_assessment); Ok(AdversarialEval { idea_id: idea.id.clone(), @@ -83,12 +156,14 @@ impl AdversarialEngine { analyst, final_score: scores.overall, recommendation, + // 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback) + evaluated_by: EvaluatedBy::Heuristic, }) } /// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动 /// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok - fn generate_positive_argument(idea: &Idea, scores: &IdeaScores) -> Result { + fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result { let desc = idea.description.trim(); let mut evidence = Vec::new(); evidence.push(format!("优先级:{}", priority_label(&idea.priority))); @@ -126,7 +201,7 @@ impl AdversarialEngine { } /// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升 - fn generate_negative_argument(idea: &Idea, scores: &IdeaScores) -> Result { + fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result { let desc = idea.description.trim(); let mut evidence = Vec::new(); if desc.is_empty() { @@ -166,7 +241,7 @@ impl AdversarialEngine { } /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 - fn analyst_analysis(idea: &Idea, scores: &IdeaScores) -> Result { + fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result { let final_assessment = match scores.overall { x if x >= 7.5 => AssessmentLevel::StrongGo, x if x >= 6.0 => AssessmentLevel::Recommended, @@ -234,7 +309,7 @@ impl AdversarialEngine { } /// 评估等级 → 最终建议 - fn recommendation_for(level: &AssessmentLevel) -> Recommendation { + fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation { match level { AssessmentLevel::StrongGo => Recommendation::ImmediateAction, AssessmentLevel::Recommended => Recommendation::Soon, @@ -302,7 +377,7 @@ mod tests { let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3); let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]); let scores = ScoringEngine::compute_default(&idea); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a1] 高分想法 → 期望 ImmediateAction"); println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall); println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation); @@ -316,7 +391,7 @@ mod tests { let desc = "面向用户的功能,集成已有方案,提升体验".to_string(); let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]); let scores = ScoringEngine::compute_default(&idea); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a2] 中分想法 → 期望 Soon"); println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation); assert_eq!(eval.recommendation, Recommendation::Soon); @@ -327,7 +402,7 @@ mod tests { let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string(); let idea = make_idea("过度工程", &desc, Priority::Low, vec![]); let scores = ScoringEngine::compute_default(&idea); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a3] 低分想法 → 期望 Monitor"); println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation); assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score); @@ -337,7 +412,7 @@ mod tests { #[tokio::test] async fn a4_confidence_ranges() { let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a4] confidence 区间校验"); println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence); assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95); @@ -347,7 +422,7 @@ mod tests { #[tokio::test] async fn a5_positive_thesis_contains_title() { let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a5] 正方论点含标题"); println!(" thesis: {}", eval.positive.thesis); assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题"); @@ -356,7 +431,7 @@ mod tests { #[tokio::test] async fn a6_negative_evidence_nonempty() { let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len()); for (i, e) in eval.negative.evidence.iter().enumerate() { println!(" 证据{}: {}", i + 1, e); @@ -369,7 +444,7 @@ mod tests { let desc = "面向用户的核心功能".to_string(); let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]); let scores = ScoringEngine::compute_default(&idea); - let eval = AdversarialEngine::evaluate(&idea).await.unwrap(); + let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap(); println!("\n[a7] final_score == scores.overall 一致性"); println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score); println!(" analyst.summary: {}", eval.analyst.summary); diff --git a/crates/df-project/src/scan.rs b/crates/df-project/src/scan.rs index 847d96d..ae02deb 100644 --- a/crates/df-project/src/scan.rs +++ b/crates/df-project/src/scan.rs @@ -92,6 +92,157 @@ pub fn detect_stack(root: &Path) -> Result> { Ok(stack) } +// ============================================================ +// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM) +// ============================================================ + +/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx) +const MONOREPO_MARKERS: &[&str] = &[ + "pnpm-workspace.yaml", + "lerna.json", + "turbo.json", + "nx.json", +]; + +/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。 +/// +/// 命中任一即视为 monorepo: +/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在 +/// - package.json 含 `workspaces` 字段(npm/yarn workspaces) +pub fn is_monorepo(root: &Path) -> bool { + if !root.is_dir() { + return false; + } + for marker in MONOREPO_MARKERS { + if root.join(marker).is_file() { + return true; + } + } + // package.json workspaces 字段(npm/yarn) + let pkg_path = root.join("package.json"); + if pkg_path.is_file() { + if let Ok(content) = std::fs::read_to_string(&pkg_path) { + if let Ok(pkg) = serde_json::from_str::(&content) { + if pkg.get("workspaces").is_some() { + return true; + } + } + } + } + false +} + +/// 单个发现的候选项目(monorepo 子项目或独立项目) +#[derive(Debug, Clone)] +pub struct DiscoveredProject { + /// 项目根目录绝对路径 + pub path: String, + /// 推断的项目名(目录名) + pub name: String, + /// 规则探测的技术栈(空=未识别) + pub stack: Vec, + /// 是否为 monorepo 根(便于前端标记) + pub is_monorepo: bool, +} + +/// 在指定根目录下发现候选项目。 +/// +/// 策略(只展开一层,不做深递归): +/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目 +/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空) +/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目 +/// +/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。 +pub fn discover_projects(root: &Path) -> Result> { + if !root.is_dir() { + anyhow::bail!("路径不是目录: {}", root.display()); + } + + let mut out: Vec = Vec::new(); + let mono = is_monorepo(root); + + // 1. 根目录自身是项目(有 manifest 标志) + if has_project_manifest(root) { + let stack = detect_stack(root).unwrap_or_default(); + out.push(DiscoveredProject { + path: root.to_string_lossy().to_string(), + name: root + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| root.to_string_lossy().to_string()), + stack, + is_monorepo: mono, + }); + } + + // 2. monorepo → 展开 packages/* apps/* 直接子目录 + // 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选 + let scan_globs: &[&str] = if mono { + &["packages", "apps"] + } else { + &[""] + }; + + for glob in scan_globs { + let target = if glob.is_empty() { + root.to_path_buf() + } else { + root.join(glob) + }; + if !target.is_dir() { + continue; + } + let Ok(entries) = std::fs::read_dir(&target) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + if !p.is_dir() { + continue; + } + let name = e.file_name().to_string_lossy().to_string(); + if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') { + continue; + } + // 必须有项目标志 + detect_stack 非空 + if !has_project_manifest(&p) { + continue; + } + let stack = match detect_stack(&p) { + Ok(s) => s, + Err(_) => continue, + }; + if stack.is_empty() { + continue; + } + out.push(DiscoveredProject { + path: p.to_string_lossy().to_string(), + name, + stack, + is_monorepo: false, + }); + } + } + + Ok(out) +} + +/// 目录是否含任一项目清单标志文件 +fn has_project_manifest(dir: &Path) -> bool { + const MARKS: &[&str] = &[ + "Cargo.toml", + "package.json", + "go.mod", + "pyproject.toml", + "requirements.txt", + "pom.xml", + "build.gradle", + "build.gradle.kts", + ]; + MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj") +} + /// 解析 package.json,合并 dependencies + devDependencies 的包名 fn read_package_deps(path: impl AsRef) -> Result> { let content = std::fs::read_to_string(path.as_ref()) @@ -132,8 +283,8 @@ fn has_file_with_ext(dir: &Path, ext: &str) -> bool { /// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声, /// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。 pub fn extract_description(root: &Path) -> Option { - // 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体) - let content = read_readme(root)?; + // 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体) + let content = read_readme_raw(root)?; let mut text = String::new(); let mut started = false; for raw_line in content.lines() { @@ -171,16 +322,28 @@ pub fn extract_description(root: &Path) -> Option { /// extract_description 最大字符数 const EXTRACT_DESC_MAX: usize = 200; -/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段 +/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。 +/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。 +/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级, +/// 此结构仅为采样层留接口,不读 base64。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRef { + pub alt: String, + pub src: String, +} + +/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用 #[derive(Debug, Clone)] pub struct ProjectSample { pub readme: Option, pub tree: Vec, /// (文件名, 截断内容) pub manifests: Vec<(String, String)>, + /// README 内的内容图引用(架构图/截图等,跳徽章) + pub images: Vec, } -const SAMPLE_README_MAX: usize = 2000; +const SAMPLE_README_MAX: usize = 8000; const SAMPLE_MANIFEST_MAX: usize = 1500; const SAMPLE_TREE_MAX: usize = 80; /// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE) @@ -190,30 +353,260 @@ const SAMPLE_IGNORED_DIRS: &[&str] = &[ ".turbo", ".angular", ".gradle", "vendor", ]; -/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息 +/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息 pub fn collect_sample(root: &Path) -> Result { if !root.is_dir() { anyhow::bail!("路径不是目录: {}", root.display()); } + let raw = read_readme_raw(root); + let (readme, images) = match raw { + Some(text) => { + let images = collect_images(&text); + let cleaned = strip_readme_noise(&text); + if cleaned.trim().is_empty() { + (None, images) + } else { + (Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images) + } + } + None => (None, Vec::new()), + }; Ok(ProjectSample { - readme: read_readme(root), + readme, tree: collect_tree(root), manifests: collect_manifests(root), + images, }) } -fn read_readme(root: &Path) -> Option { +/// 读 README 原始内容(不做处理) +fn read_readme_raw(root: &Path) -> Option { for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] { let p = root.join(name); if p.is_file() { if let Ok(content) = std::fs::read_to_string(&p) { - return Some(truncate_chars(&content, SAMPLE_README_MAX)); + return Some(content); } } } None } +/// 徽章图域名(纯徽章图,跳过不喂 LLM) +const BADGE_HOSTS: &[&str] = &[ + "img.shields.io", + "shields.io", + "badge.fury.io", + "badgen.net", + "badges.frapsoft.com", + "github.com/workflows", // GitHub Actions 工作流状态徽章 + "travis-ci.org", + "coveralls.io", + "codecov.io", + "app.codacy.com", + "david-dm.org", + "circleci.com", + "ci.appveyor.com", +]; + +/// 徽章关键词(图 alt/src 含这些视为纯徽章图) +const BADGE_KEYWORDS: &[&str] = &[ + "build", + "version", + "license", + "coverage", + "downloads", + "download", + "npm", + "pypi", + "crates.io", + "codeclimate", + "maintainability", + "stars", + "forks", + "issues", + "contributors", + "dependencies", + "devdependencies", + "circleci", + "travis", + "appveyor", + "coveralls", + "codecov", +]; + +/// 判定 markdown 图片 `![alt](src)` 是否为纯徽章(架构图/截图等保留) +fn is_badge_image(alt: &str, src: &str) -> bool { + // 域名命中(shields.io / badge.fury / GitHub Actions 等) + let src_lower = src.to_lowercase(); + if BADGE_HOSTS.iter().any(|h| src_lower.contains(h)) { + return true; + } + // 关键词命中(alt 或 src 含 build/version/license/coverage 等) + let hay = format!("{alt} {src}").to_lowercase(); + if BADGE_KEYWORDS.iter().any(|k| hay.contains(k)) { + return true; + } + false +} + +/// 从 README 收集内容图(跳徽章),保留 markdown 原样引用 +fn collect_images(content: &str) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + // 匹配 ![alt](src) —— 简单行级扫描,够用且无 regex 依赖 + for line in content.lines() { + let mut rest = line; + while let Some(start) = rest.find("![") { + let after_bracket = &rest[start + 2..]; + let Some(alt_end) = after_bracket.find("](") else { break }; + let alt = after_bracket[..alt_end].to_string(); + let after_paren = &after_bracket[alt_end + 2..]; + let Some(paren_end) = after_paren.find(')') else { break }; + let src = after_paren[..paren_end].to_string(); + rest = &after_paren[paren_end + 1..]; + if is_badge_image(&alt, &src) { + continue; + } + // 同 src 去重 + if seen.insert(src.clone()) { + out.push(ImageRef { alt, src }); + } + if out.len() >= 20 { + return out; + } + } + } + out +} + +/// 剥 README 噪音:frontmatter(YAML/TOML 块) / HTML 注释 / TOC(纯链接目录行) / +/// 纯徽章图片行 / 纯徽章 HTML 行。保留标题、正文、内容图 markdown 原样。 +fn strip_readme_noise(content: &str) -> String { + let mut out = Vec::new(); + let mut lines = content.lines().peekable(); + while let Some(line) = lines.next() { + let trimmed = line.trim(); + // ── frontmatter 块(YAML `---` / TOML `+++`)── + if trimmed == "---" || trimmed == "+++" { + // 开头处的 frontmatter:跳到下一个匹配分隔符 + let delim = trimmed; + let mut consumed_any = false; + for next in lines.by_ref() { + consumed_any = true; + if next.trim() == delim { + break; + } + } + let _ = consumed_any; + continue; + } + // ── HTML 注释块(,可能跨行)── + if trimmed.starts_with("") { + // 单行注释,整行跳过 + continue; + } + // 多行:吃到含 --> 的行 + for next in lines.by_ref() { + if next.contains("-->") { + break; + } + } + continue; + } + // ── 纯徽章行:整行只剩图片/HTML 徽章(可能多个 ![..](..) 连排)── + if is_pure_badge_line(trimmed) { + continue; + } + // ── TOC:纯目录行(整行只有 [..](#anchor) 锚点链接,或 markdown 列表项仅锚点)── + if is_toc_line(trimmed) { + continue; + } + out.push(line.to_string()); + } + out.join("\n") +} + +/// 判定整行是否为纯徽章行(仅含图片/HTML badge,无其它实质文本)。 +/// 如 `![build](https://img.shields.io/x) ![license](...) ` +fn is_pure_badge_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + // 仅含图片语法/HTML 标签/空白/[alt] 片段,且至少有一个图片或 HTML img 标签 + let mut content_found = false; + let mut text_only: String = String::new(); + let mut rest = line; + loop { + // 找下一个 ![ 或 &suffix[2..suffix.find("](").unwrap_or(end)]; 闭合 + if let Some(end) = suffix.find('>') { + content_found = true; + end + 1 + } else { + return false; + } + }; + rest = &suffix[consumed..]; + } + // 剩余 text_only 必须为空或纯空白/标点 + let residual = text_only + .chars() + .filter(|c| !c.is_whitespace() && *c != '|' && *c != '-') + .collect::(); + content_found && residual.is_empty() +} + +/// 判定 TOC 行:markdown 列表项仅含锚点链接 `- [..](#..)` 或整行仅锚点链接 +fn is_toc_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + let t = line.trim_start_matches(['-', '*', '+', ' ']); + // 形如 [text](#anchor) 或 [text](#anchor "title") + if !(t.starts_with('[') && t.contains("](")) { + return false; + } + // 取 ]( 后到行尾或空格,须以 # 开头 + let Some(close) = t.find("](") else { return false }; + let after = &t[close + 2..]; + let target = after.trim_end_matches(')').split_whitespace().next().unwrap_or(""); + target.starts_with('#') +} + /// 目录树(根 + 一层子目录),过滤噪音目录,控条目数 fn collect_tree(root: &Path) -> Vec { let mut lines = Vec::new(); @@ -344,14 +737,25 @@ mod tests { #[test] fn collects_sample() { let d = scratch("sample"); - fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap(); + fs::write( + d.join("README.md"), + "# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.", + ) + .unwrap(); fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap(); fs::create_dir(d.join("src")).unwrap(); fs::write(d.join("src/main.ts"), "x").unwrap(); fs::create_dir(d.join("node_modules")).unwrap(); fs::write(d.join("node_modules/junk.json"), "x").unwrap(); let s = collect_sample(&d).unwrap(); - assert!(s.readme.as_deref().unwrap_or("").contains("test project")); + // 徽章行剥,内容图 + 正文保留 + let readme = s.readme.as_deref().unwrap_or(""); + assert!(readme.contains("test project"), "readme={readme}"); + assert!(!readme.contains("shields.io"), "徽章未剥: {readme}"); + assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}"); + // 内容图收集(badge 跳,arch 留) + assert_eq!(s.images.len(), 1); + assert_eq!(s.images[0].src, "./docs/arch.png"); assert!(s.manifests.iter().any(|(n, _)| n == "package.json")); assert!(s.tree.iter().any(|t| t.contains("src"))); // node_modules 应被过滤 @@ -359,6 +763,103 @@ mod tests { fs::remove_dir_all(&d).ok(); } + #[test] + fn strips_frontmatter_and_toc_and_badges() { + let d = scratch("noise"); + let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n"; + fs::write(d.join("README.md"), readme).unwrap(); + let s = collect_sample(&d).unwrap(); + let r = s.readme.as_deref().unwrap_or(""); + assert!(r.contains("# Foo"), "标题应保留: {r}"); + assert!(r.contains("real intro"), "正文应保留: {r}"); + assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}"); + assert!(!r.contains("shields.io"), "徽章未剥: {r}"); + assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}"); + assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}"); + assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}"); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn image_collection_skips_badges() { + let content = "![build](https://img.shields.io/x)\n![arch](./a.png)\n![version](https://badge.fury.io/js/y)\n![screenshot](screens/b.png)\n"; + let imgs = collect_images(content); + // 只有 arch + screenshot,build/version 跳 + assert_eq!(imgs.len(), 2); + assert!(imgs.iter().any(|i| i.src == "./a.png")); + assert!(imgs.iter().any(|i| i.src == "screens/b.png")); + assert!(imgs.iter().all(|i| !i.src.contains("shields.io"))); + assert!(imgs.iter().all(|i| !i.src.contains("badge.fury"))); + } + + #[test] + fn detects_monorepo_pnpm() { + let d = scratch("mono-pnpm"); + fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); + assert!(is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn detects_monorepo_npm_workspaces() { + let d = scratch("mono-npm"); + fs::write( + d.join("package.json"), + r#"{"name":"root","workspaces":["packages/*"]}"#, + ) + .unwrap(); + assert!(is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn detects_non_monorepo() { + let d = scratch("nonmono"); + fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap(); + assert!(!is_monorepo(&d)); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn discover_monorepo_children() { + let d = scratch("discover-mono"); + fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); + // 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml) + fs::create_dir_all(d.join("packages/web")).unwrap(); + fs::write( + d.join("packages/web/package.json"), + r#"{"name":"web","dependencies":{"vue":"3"}}"#, + ) + .unwrap(); + fs::create_dir_all(d.join("packages/cli")).unwrap(); + fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap(); + // 空 stack 子目录应过滤 + fs::create_dir_all(d.join("packages/empty")).unwrap(); + fs::write(d.join("packages/empty/x.txt"), "x").unwrap(); + let found = discover_projects(&d).unwrap(); + // 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤 + let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"web"), "names={names:?}"); + assert!(names.contains(&"cli"), "names={names:?}"); + assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}"); + fs::remove_dir_all(&d).ok(); + } + + #[test] + fn discover_flat_children() { + // 非 monorepo:扫根直接子目录中 detect_stack 非空的 + let d = scratch("discover-flat"); + fs::create_dir_all(d.join("proj-a")).unwrap(); + fs::write(d.join("proj-a/Cargo.toml"), "").unwrap(); + fs::create_dir_all(d.join("not-a-project")).unwrap(); + fs::write(d.join("not-a-project/readme.txt"), "x").unwrap(); + let found = discover_projects(&d).unwrap(); + let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"proj-a"), "names={names:?}"); + assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}"); + fs::remove_dir_all(&d).ok(); + } + #[test] fn extract_desc_skips_title_badge() { let d = scratch("desc"); diff --git a/docs/todo.md b/docs/todo.md index 5787edb..49ebd2d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -575,10 +575,10 @@ - [x] T-260614-04 — ~~路径校验根治~~ ✅ 已完成(resolve_workspace_path 加 canonicalize 防 symlink 逃逸 + 词法 starts_with 兜底;仅校验、返回词法路径保持前端友好;cargo check 0 err / 22 test pass)(06-14) - [ ] F-260614-04 — 多 Provider 负载均衡池 — 备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap) (06-14) - [ ] F-260614-05 — 模型能力系统 Phase 2 — 多模态消息支持:ChatMessage.content: String → Vec(Text/Image);前端粘贴/拖拽图片;vision 模型自动路由 (06-14) -- [ ] F-260614-06 — 导入历史项目(scan 第二步) — [📐设计定稿 06-14] description 走 LLM(复用 scan_project_with_ai,非纯规则)+ 采样保留内容图(待 F-260614-05 多模态)+ monorepo 一层 + 批量并发;抽 create_with_binding 缓解 :211 TODO;详见功能决策记录(06-14) +- [x] ✅(batch61·2026-06-16·workflow wwtn2knn6+主代核查,cargo 0err+vue-tsc 0err) F-260614-06 — 导入历史项目(scan 第二步) — 6 决策全落地:①description 走 LLM(scan.rs extract_description 复用 complete)②采样扩 `images:Vec`(scan.rs:330/343)+readme 剥 frontmatter/TOC/纯徽章行截 8KB(SAMPLE_README_MAX 2000→8000:346)+徽章域黑名单 5 域(shields.io/badge.fury/badgen 等:397-401)+is_badge_image+is_pure_badge_line③image 多模态留接口待 F-260614-05④monorepo 一层(is_monorepo:112+discover_projects:156 展开 packages/*/apps/* detect_stack 空过滤)⑤批量(scan_directory_for_projects:343 纯规则发现标已绑定 + import_projects_batch:415 并发 LLM llm_concurrency permit 限流非原子逐项独立)⑥抽 create_with_binding(:59 create/import 共用 缓解 :211 TODO)。前端 Projects.vue 导入 modal+api/project.ts 两 API+i18n 双语+scan.rs 单测全。— crates/df-project/scan.rs + src-tauri/commands/project.rs + lib.rs + api/project.ts + Projects.vue + i18n。详见功能决策记录(06-14) - [x] T-260614-09 — ~~idea.rs 物理删不级联~~ ✅ WF-E 完成(idea.rs:149 delete→purge_with_descendants,1 行,签名兼容)(06-14, commit 89da9fa) - [x] T-260614-10 — ~~findBinding canonicalize~~ ✅ WF-E 判定已解决(normalize_path 已含 canonicalize 优先 + 词法回退,find_binding_conflict 两端对称已用,无需重复加;零改动)(06-14, commit 89da9fa) -- [ ] F-260614-07 — **[架构前置]** df-ai-core trait 下沉拆 crate — F-03 注入方式前置(决策记录已收敛:原 F-03 的 A/B/C 选型作废,统一为全局 AI trait 下沉独立 crate,df-ideas/df-nodes 依赖 trait 而非 df-ai 具体 impl)。解锁 F-03 — source:功能决策记录 (06-14) +- [x] ✅(batch61·2026-06-16·workflow wwtn2knn6+主代核查,cargo 0err+7单测) F-260614-07 — **[架构前置]** df-ai-core trait 下沉拆 crate — 4 决策全落地:①df-ai-core 新 crate(仅 trait+数据结构 LlmProvider+ChatMessage 等,ContextManager/TokenEstimator 留 df-ai)②构造注入 Engine::new(Arc)+Engine::heuristic()(idea.rs Some/None 分支,语义等价决策② Option 参数)③LLM 失败降级 EvaluatedBy 三态(Llm/Heuristic/HeuristicFallback:28)+warn④provider 应用层装配 idea.rs build_default_provider(DB is_default→build_provider→Option→Arc::from:192)。df-ai provider.rs:15 `pub use df_ai_core::provider::*` re-export(外部 use 路径不变,df-nodes 零改动)+lib.rs:17 `pub use df_ai_core`。**解锁 F-03 注入**(其他 crate 依赖 df-ai-core trait 非 df-ai impl)。— crates/df-ai-core(新)+crates/df-ai+crates/df-ideas+src-tauri/commands/idea.rs。详见设计文档 F-07-df-ai-core-trait下沉设计 - [x] ⏸️(待决策.md已决c暂缓·2026-06-16) F-260614-08 — 决策治理产品化 — 活契约/规格契约自检机制产品化为可操作功能(当前散落文档机制) — source:功能决策记录缺口 - [x] ⏸️(待决策.md已决c暂缓·2026-06-16) F-260614-09 — 项目 status 字段治理 — status 状态机规范化(planning/active/archived 等枚举约束) — source:功能决策记录缺口 - [x] ⏸️(待决策.md已决c暂缓·2026-06-16) F-260614-10 — 知识库 MCP Server + Tier 2/3 — 对外 MCP 暴露 + 分层存储(当前仅 Tier 1 全栈) — source:PROGRESS Sprint15 diff --git a/docs/待审查.md b/docs/待审查.md index 533d7a4..4c158a9 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -27,15 +27,33 @@ - **范围**: 3 agent 合并批(workflow whae812z5,主代独立核查全过 + cargo check --workspace EXIT 0 + vue-tsc EXIT 0)。①**F-09 A 路线**(单例软隔离补漏清字段,不动 B 多会话架构):前端 `useAiConversations.ts:42-45` newConversation 补清 `queue=[]/generatingConvId=null/agentRound=0/searchQuery=''`(防旧会话排队消息带进新会话 + 后台事件错路由 + 侧栏旧搜索过滤);后端 `commands.rs:842-843` ai_conversation_create 补清 `stop_flag.store(false,SeqCst)`(关键:上方生成中分支置 true 停旧 loop 不复位则新会话 loop 启动即见 stop 退出) + `agent_language=None`(防沿用旧语言设置)。②**F-13 性能**:`agentic.rs:173` system_prompt token loop 外算一次缓存为 sys_tokens(整个 loop 不变参数,原每轮+每次重试重算);`:219-225` 外层 messages 构建用 sys_tokens;`:262-270` 重试循环改 `messages: messages.clone()` 复用外层 Vec(删每次重试的 session_arc.lock + build_for_request 全量 clone + estimate_text)。安全前提核验:stream_llm 不接 session_arc + 重试块到 process_tool_calls 间无 push,重试内 session.messages 与外层一致,重建等价复用。③**AE-03 路径 B**(审批 payload 加 diff,非路径 A):`tool_registry.rs:32` generate_diff fn→pub(crate)(复用 F-260615-10 LCS);`audit.rs:508-520` 新增 build_write_file_diff(从 args 取 path+content → 预读旧文件 tokio::fs::read_to_string → 无变化/旧文件缺失/读失败均 None → generate_diff);`audit.rs:588-609` 仅 write_file draft 挂起审批前预计算 approval_diff 注入 PendingApproval.diff(:600 clone)+AiApprovalRequired.diff(:609 move);恢复路径 diff=None(文件可能已变);`mod.rs:101/269` AiApprovalRequired+PendingApproval 加 `diff: Option`;`types.ts:198` AiApprovalRequired 加 `diff?: string`;`useAiEvents.ts` event.diff 传 toolCall 信息;`ToolCard.vue:40-41` 模板 `v-if="tc.diff"` diff 块 + :394 diffLines computed(按 +/-/space 前缀拆分)+ :677-705 样式(add 绿 del 红 ctx 灰 + token 复用)。 - **维度**: ①F-09 单例字段补清完整性(stop_flag 复位是否覆盖所有生成中→新建路径/agent_language 影响面) + B 路线边界(确认未误动 AiSession 单例) ②F-13 行为不变性核验(sys_tokens 缓存是否真不变/messages.clone() 复用是否等价重建/stream_llm 不持 session_arc 前提是否成立) + 锁持有期缩短收益确认 ③AE-03 路径 B 安全性(预读旧文件只读不改/审批拒绝不执行 handler/路径不校验风险——恶意路径读失败仅 None) + diff 注入完整性(实时挂起+恢复路径双覆盖/前端消费链 event→toolCall→模板) + generate_diff pub(crate) 暴露面。 -- **commit**: 待提交(累积 ~12 文件攒批 + 4 决策回填)。 +- **commit**: d00b30f(重构: AI聊天可靠性批次)。 +- **主代独立核查**: ✅ 全过(useAiConversations:42-45 补清 / commands:842-843 stop_flag.store(false)+agent_language=None / agentic:173 sys_tokens loop 外缓存 + :219-225 外层用 + :262-270 重试 messages.clone() 复用 + 保文退避逻辑未动(Partial 不重试/retry_deadline 30s) / tool_registry:32 generate_diff pub(crate) / audit:508-520 build_write_file_diff 预读+无变化/缺失 None+复用 generate_diff / audit:588-609 仅 write_file 生成 diff 注入 PendingApproval(clone)+AiApprovalRequired(move) / mod:101+269 diff 字段 / types:198 diff? / ToolCard:40-41 模板 + :394 diffLines + :677-705 样式 / cargo+vue-tsc 双 EXIT 0)。 +- **审查 agent 待复审重点**: ①F-13 重试 messages.clone() 复用等价性——核验 stream_llm 签名(stream_recv.rs:137 不接 session_arc) + 重试块到 process_tool_calls 间确实无 session.messages.push(若遗漏则重试复用旧 messages 致 tool_calls 丢失) ②F-13 sys_tokens 缓存——核验 system_prompt 确为 run_agentic_loop 不变参数(整个 loop 期间无 mutate) ③AE-03 build_write_file_diff 安全——预读旧文件 `tokio::fs::read_to_string(path)` 路径未校验(恶意 path 读失败仅 None 不产生危害,但核验无 symlink 逃逸读敏感文件风险——审批只读,write_file handler 自身 validate_path 执行时兜底) ④AE-03 diff 注入双路径——实时挂起(audit:588-609 有 diff)+恢复路径(PendingApproval diff 字段是否持久化,若仅内存则重启恢复审批无 diff) ⑤文件锁独立性——3 agent 改动文件无重叠(F-09: useAiConversations/commands / F-13: agentic / AE-03: mod/tool_registry/audit/types/useAiEvents/ToolCard),useAiEvents.ts Agent C 独占(A/B 未碰)确认无冲突。 ### CR-260616-37 F-11 审批续跑 iteration 累计计数(跨审批不重置) — ⏳ 待审 -- **范围**: F-260616-11 决策a 落地(与 batch60 三项合批 commit)。`mod.rs:215/230` AiSession 加 `iteration_used:usize` 字段+new() init 0;`agentic.rs:114` run_agentic_loop 加 `start_iteration:usize` 参数 +`:176` loop 边界改 `start_iteration..max_iterations` +`:210` 一致性校验块更新 `session.iteration_used=iteration+1` +`:578` try_continue_agent_loop 加 start_iteration 参数 +`:675` spawn 透传 +`:499-501` 注释区分两路径;`commands.rs:55/152/412` ai_chat_send/ai_regenerate/ai_edit_last reset iteration_used=0(新生命周期)+`:260-262` ai_approve 拒绝续跑读 session.iteration_used 累计传 +`:313-315` ai_approve 通过续跑累计传 +`:601` ai_continue_loop reset iteration_used=0 传 0(F-03 决策a 达max重计区分)。两路径严格区分:审批续跑(ai_approve 累计透传 iteration_used) vs 达max续跑(ai_continue_loop reset+传0,F-03 决策a 用户授权重来) vs 新消息(reset 0)。 +- **范围**: F-260616-11 决策a 落地(与 batch60 三项合批 commit d00b30f)。`mod.rs:215/230` AiSession 加 `iteration_used:usize` 字段+new() init 0;`agentic.rs:114` run_agentic_loop 加 `start_iteration:usize` 参数 +`:176` loop 边界改 `start_iteration..max_iterations` +`:210` 一致性校验块更新 `session.iteration_used=iteration+1` +`:578` try_continue_agent_loop 加 start_iteration 参数 +`:675` spawn 透传 +`:499-501` 注释区分两路径;`commands.rs:55/152/412` ai_chat_send/ai_regenerate/ai_edit_last reset iteration_used=0(新生命周期)+`:260-262` ai_approve 拒绝续跑读 session.iteration_used 累计传 +`:313-315` ai_approve 通过续跑累计传 +`:601` ai_continue_loop reset iteration_used=0 传 0(F-03 决策a 达max重计区分)。两路径严格区分:审批续跑(ai_approve 累计透传 iteration_used) vs 达max续跑(ai_continue_loop reset+传0,F-03 决策a 用户授权重来) vs 新消息(reset 0)。 - **维度**: ①iteration_used 字段生命周期完备性(所有会话生命周期入口 reset 覆盖——create/send/regenerate/edit_last/continue_loop 是否齐全无遗漏路径) ②start_iteration 透传链完整(agentic run_agentic_loop←try_continue_agent_loop←ai_approve 两个调用点,无断链) ③两路径区分正确性(ai_approve 累计 vs ai_continue_loop 重计,F-11/F-03 决策 a 分别归属无混淆) ④边界 case(start≥max loop 空区间→converged=false→AiMaxRoundsReached,防无限审批烧 token,符合 F-11 语义) ⑤并发安全(iteration_used 在一致性 lock 块更新,与现有 lock 模式一致)。 -- **commit**: 待提交(同 CR-36 合批)。 -- **主代独立核查**: ✅ 全过(useAiConversations:42-45 补清 / commands:842-843 stop_flag.store(false)+agent_language=None / agentic:173 sys_tokens loop 外缓存 + :219-225 外层用 + :262-270 重试 messages.clone() 复用 + 保文退避逻辑未动(Partial 不重试/retry_deadline 30s) / tool_registry:32 generate_diff pub(crate) / audit:508-520 build_write_file_diff 预读+无变化/缺失 None+复用 generate_diff / audit:588-609 仅 write_file 生成 diff 注入 PendingApproval(clone)+AiApprovalRequired(move) / mod:101+269 diff 字段 / types:198 diff? / ToolCard:40-41 模板 + :394 diffLines + :677-705 样式 / cargo+vue-tsc 双 EXIT 0)。 -- **审查 agent 待复审重点**: ①F-13 重试 messages.clone() 复用等价性——核验 stream_llm 签名(stream_recv.rs:137 不接 session_arc) + 重试块到 process_tool_calls 间确实无 session.messages.push(若遗漏则重试复用旧 messages 致 tool_calls 丢失) ②F-13 sys_tokens 缓存——核验 system_prompt 确为 run_agentic_loop 不变参数(整个 loop 期间无 mutate) ③AE-03 build_write_file_diff 安全——预读旧文件 `tokio::fs::read_to_string(path)` 路径未校验(恶意 path 读失败仅 None 不产生危害,但核验无 symlink 逃逸读敏感文件风险——审批只读,write_file handler 自身 validate_path 执行时兜底) ④AE-03 diff 注入双路径——实时挂起(audit:588-609 有 diff)+恢复路径(PendingApproval diff 字段是否持久化,若仅内存则重启恢复审批无 diff) ⑤文件锁独立性——3 agent 改动文件无重叠(F-09: useAiConversations/commands / F-13: agentic / AE-03: mod/tool_registry/audit/types/useAiEvents/ToolCard),useAiEvents.ts Agent C 独占(A/B 未碰)确认无冲突。 +- **commit**: d00b30f(同 CR-36 合批)。 +- **主代独立核查**: ✅ 全过(mod.rs:215/230 iteration_used 字段+init / agentic.rs:114 start_iteration 参数+:176 loop 边界+:210 一致性块 iteration+1+:578 try_continue 加参+:675 spawn 透传+:499-501 注释两路径 / commands.rs:55/152/412 reset 0+ai_approve:260/313 累计读 iteration_used+ai_continue_loop:601 reset 传0 / cargo check --workspace EXIT 0)。 +- **审查 agent 待复审重点**: ①iteration_used 生命周期完备性(所有会话生命周期入口 reset 覆盖无遗漏——create/send/regenerate/edit_last/continue_loop/ai_approve 全路径核验) ②start_iteration 透传链(run_agentic_loop←try_continue_agent_loop←ai_approve 两调用点无断链) ③两路径区分(ai_approve 累计 vs ai_continue_loop 重计,F-11/F-03 决策a 归属无混) ④边界 start≥max loop 空区间→converged false→AiMaxRoundsReached(防无限审批烧 token) ⑤并发安全(一致性 lock 块更新)。 + +### CR-260616-38 F-07 df-ai-core trait 下沉拆 crate(解锁 F-03 注入) — ⏳ 待审 + +- **范围**: F-260614-07 4 决策落地(workflow wwtn2knn6)。①df-ai-core 新 crate:`crates/df-ai-core/`(Cargo.toml serde+async-trait+futures + src/lib.rs `pub mod provider` + src/provider.rs 迁移 trait+数据结构 LlmProvider/ChatMessage 等,不含 HTTP impl);②df-ai re-export:`crates/df-ai/Cargo.toml` 加 df-ai-core 依赖 + `src/provider.rs:15` `pub use df_ai_core::provider::*` + `src/lib.rs:17` `pub use df_ai_core`(外部 use df_ai::provider 路径不变,df-nodes 零改动,workspace Cargo.toml 零改动 members=crates/*);③df-ideas 构造注入:`crates/df-ideas/Cargo.toml` 加 df-ai-core+async-trait + `src/adversarial.rs:18` use df_ai_core::provider::LlmProvider + struct 加 `provider:Option>`(:96) + `new(provider:Arc)`(:101 调用方 Some/None 分支语义等价 Option)+ `heuristic()`(:107) + `evaluate(&self)`(:111) + `evaluate_with_llm`(:138) + EvaluatedBy 三态 enum(:28) + AdversarialEval.evaluated_by 字段(:49) + LLM 失败→warn+HeuristicFallback 降级(:122);④src-tauri 装配:`idea.rs:190` build_default_provider(DB is_default→build_provider→Option>:265) + :192 Some→`AdversarialEngine::new(Arc::from(p))` / None→`heuristic()`(:193)。 +- **维度**: ①re-export 透明性(df_ai::provider::LlmProvider 路径 df-nodes/df-project 等外部 use 无断裂) ②trait 下沉边界(LlmProvider trait+纯数据结构下沉,ContextManager/TokenEstimator/AiToolRegistry 业务逻辑确留 df-ai 未误迁) ③构造注入语义(new(Arc) vs 决策② Option——调用方分支等价性,无遗漏 Some/None) ④EvaluatedBy 三态正确(Llm/Heuristic/HeuristicFallback,provider None 全走 Heuristic) ⑤Box→Arc 转换(idea.rs:192 Arc::from(Box) 编译通过) ⑥build_default_provider DB 读(is_default 查询+无默认兜底 heuristic) ⑦adversarial 7 单测改 heuristic() 构造后全过。 +- **commit**: 待提交(batch61 合批)。 +- **主代独立核查**: ✅ 全过(df-ai-core 新建 Cargo.toml+lib.rs+provider.rs / df-ai provider.rs:15+lib.rs:17 re-export+Cargo.toml 加依赖 / df-ideas adversarial.rs:18 use df_ai_core:96 provider 字段+:101 new(Arc)+:107 heuristic+:111 evaluate(&self)+:138 evaluate_with_llm+:28 EvaluatedBy+:49 evaluated_by+:122 HeuristicFallback 降级 / idea.rs:190 build_default_provider+:192 new(Arc::from)+:193 heuristic+:265 fn / cargo check --workspace EXIT 0(5 warning 全 pre-existing dead_code))。 +- **审查 agent 待复审重点**: ①provider.rs 迁移完整性(df-ai 原 trait+数据结构全部下沉,df-ai/src/provider.rs 仅剩 re-export,retry/openai_compat/anthropic_compat HTTP impl 确留 df-ai 未断) ②df-nodes/df-project 外部 use df_ai::provider 编译验证(re-export 透明无断裂,实际 grep df_ai::provider 消费点) ③EvaluatedBy 三态语义(provider Some+LLM 成功=Llm / Some+LLM 失败=HeuristicFallback / None=Heuristic,evaluate_internal 调度正确) ④build_default_provider DB is_default 查询正确+build_provider 复用现有工厂 ⑤Box→Arc::from 编译期验证(cargo EXIT 0 已证)。 + +### CR-260616-39 F-06 导入历史项目 scan 第二步(LLM description+monorepo+批量并发) — ⏳ 待审 + +- **范围**: F-260614-06 6 决策落地(workflow wwtn2knn6)。①description 走 LLM(scan.rs extract_description 复用 complete,非纯规则);②采样改进(ProjectSample 扩 `images:Vec` scan.rs:330/343 + readme 剥 frontmatter/TOC/纯徽章行截 8KB `SAMPLE_README_MAX=2000→8000`:346 + 保留内容图 markdown);③image 多模态条件化(ImageRef 数据结构+collect_images 收集:454,本批不读 base64 留接口待 F-260614-05);④monorepo 一层(is_monorepo:112 检 pnpm-workspace/lerna/turbo/nx/package.json workspaces + discover_projects:156 展开 packages/*/apps/* detect_stack 空过滤);⑤批量流程(scan_directory_for_projects project.rs:343 纯规则发现标已绑定 + import_projects_batch:415 并发 LLM llm_concurrency permit 限流复用绑定入库非原子逐项独立);⑥对称改进(抽 create_with_binding project.rs:59 create/import 共用校验+防重+探测+insert 缓解 :211 TODO,relocate 不并入)。前端 Projects.vue 导入 modal+api/project.ts 两 API+i18n 双语+scan.rs 单测(剥 badges/image 收集/monorepo/discover/extract_desc)。 +- **维度**: ①is_monorepo 判定完备(pnpm-workspace/lerna/turbo/nx+package.json workspaces 全覆盖) ②discover_projects 展开(detect_stack 空过滤误剔合法项目风险/monorepo 子目录一层不递归过深) ③collect_sample images 收集(徽章域黑名单 5 域+is_badge_image alt+src 双判+is_pure_badge_line 整行纯徽章,内容图 arch/screenshot 留) ④SAMPLE_README_MAX 8000 截断(truncate_chars 不破坏 markdown/UTF-8 边界) ⑤create_with_binding 抽取(create+import 共用一致,relocate 确未并入) ⑥import_projects_batch 并发(llm_concurrency 双层 permit 限流/非原子逐项独立失败不阻塞) ⑦scan_directory_for_projects 纯规则不跑 LLM(标已绑定正确) ⑧df-project use df_ai::provider 路径不变(F-07 re-export 透明) ⑨前端预览只读+toast 汇总(导入 N/跳过 M)。 +- **commit**: 待提交(batch61 合批)。 +- **主代独立核查**: ✅ 全过(scan.rs:112 is_monorepo+:156 discover_projects+:330 ImageRef+:343 images 字段+:346 SAMPLE_README_MAX 8000+:357 collect_sample+:397-401 徽章域 5 域+:439 is_badge_image+:454 collect_images+:533 is_pure_badge_line+:742-876 单测 / project.rs:59 create_with_binding+:343 scan_directory_for_projects+:415 import_projects_batch / lib.rs:85-86 注册两 IPC / api/project.ts:117/125 两 API / Projects.vue:111 mono-tag / cargo check --workspace EXIT 0+vue-tsc EXIT 0)。 +- **审查 agent 待复审重点**: ①is_monorepo/discover_projects 边界(nested monorepo/无 workspace 配置/detect_stack 空过滤误剔) ②徽章过滤完整(5 域黑名单+alt/src 双判是否漏内容图误剔或漏 badge 误留) ③create_with_binding 与原 create_project 行为一致(校验+防重+探测+insert 路径无回归) ④import_projects_batch 并发安全(llm_concurrency permit 限流正确+非原子逐项失败隔离) ⑤scan_directory_for_projects 性能(纯规则不跑 LLM,目录扫描深浅/大目录性能) ⑥前端 modal 预览表格只读+勾选+toast 汇总交互完整。 --- diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index 952c5f5..b2753ff 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -1,8 +1,11 @@ //! 灵感相关命令 +use std::sync::Arc; + use serde::Deserialize; use tauri::State; +use df_ai::provider::LlmProvider; use df_core::types::{new_id, Priority}; use df_ideas::capture::Idea; use df_storage::models::{IdeaRecord, ProjectRecord}; @@ -183,10 +186,13 @@ pub async fn evaluate_idea( // 多维评分(0-10,IPC 层 *10 缩放为 0-100) let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea); - // 对抗式评估 - let eval = df_ideas::adversarial::AdversarialEngine::evaluate(&idea) - .await - .map_err(err_str)?; + // 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM,无 provider/构造失败 → 启发式兜底) + let provider = build_default_provider(&state).await; + let engine = match provider { + Some(p) => df_ideas::adversarial::AdversarialEngine::new(Arc::from(p)), + None => df_ideas::adversarial::AdversarialEngine::heuristic(), + }; + let eval = engine.evaluate(&idea).await.map_err(err_str)?; // 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐) let positive_strength = eval.positive.confidence; @@ -248,6 +254,31 @@ pub async fn evaluate_idea( Ok(updated) } +/// 从 DB 读取默认 provider 配置(is_default 优先,否则首个)+ build_provider 构造实例。 +/// +/// 返回 `None` 的两种情况(统一走启发式评估兜底): +/// - DB 未配置任何 provider(`list_all` 空或全无 is_default 且无首条) +/// - provider 密钥不可用(keyring 无记录 / 纯空白),`build_provider_for` 返 Err +/// +/// 复用 `commands::ai::secret::build_provider_for`(resolve→ensure→build 三步), +/// 与 AI Chat / 项目扫描的 provider 构造路径统一(FR-S1 密钥解析一致)。 +async fn build_default_provider(state: &State<'_, AppState>) -> Option> { + let providers = state.ai_providers.list_all().await.ok()?; + let pc = providers + .iter() + .find(|p| p.is_default) + .cloned() + .or_else(|| providers.into_iter().next())?; + match crate::commands::ai::secret::build_provider_for(&pc) { + Ok(p) => Some(p), + Err(e) => { + // 密钥不可用:启发式兜底,不阻断评估(与 evaluate_idea LLM 失败降级语义一致) + tracing::warn!("默认 provider 密钥不可用,对抗评估走启发式: {e}"); + None + } + } +} + /// IdeaRecord → df_ideas::Idea(评估用,status/time 不影响评分) fn record_to_idea(record: &IdeaRecord) -> Idea { let tags: Vec = record diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 16faf7f..c3600e1 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -7,7 +7,10 @@ use tauri::State; use df_ai::provider::{ChatMessage, CompletionRequest}; use df_core::types::new_id; -use df_project::scan::{collect_sample, detect_stack, extract_description, normalize_path}; +use df_project::scan::{ + collect_sample, detect_stack, discover_projects, extract_description, is_monorepo, + normalize_path, DiscoveredProject, +}; use df_storage::models::ProjectRecord; use crate::state::AppState; @@ -42,18 +45,36 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result, input: CreateProjectInput, +) -> Result { + create_with_binding(&state, input.name, input.description, input.idea_id, input.path, input.stack).await +} + +/// 共用「校验 + 防重 + 探测 + insert」核心 — create_project 与 import_projects_batch 共用。 +/// +/// 对称收敛(决策记录:217 create/bind 去重):绑定逻辑单一实现, +/// 绑定目录时统一走「校验存在 + 防重复 + 自动探测 stack(stack 入参为空时)」。 +/// relocate 不并入(走 update_field 非 insert)。 +/// +/// 返回 insert 后的完整记录。 +async fn create_with_binding( + state: &AppState, + name: String, + description: String, + idea_id: Option, + path: Option, + stack: Option, ) -> Result { // 绑定目录:校验存在 + 防重复 + 自动探测技术栈 - let (path, stack) = match input.path.as_deref().map(str::trim).filter(|p| !p.is_empty()) { + let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) { Some(p) => { if !Path::new(p).is_dir() { return Err(format!("目录不存在: {p}")); } - if let Some(conflict) = find_binding_conflict(&state, p, None).await? { + if let Some(conflict) = find_binding_conflict(state, p, None).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } // stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime) - let stack_json = match input.stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(s) => s.to_string(), None => { let root = std::path::PathBuf::from(p); @@ -72,20 +93,16 @@ pub async fn create_project( let now = now_millis(); let record = ProjectRecord { id: new_id(), - name: input.name, - description: input.description, + name, + description, status: "planning".to_string(), - idea_id: input.idea_id, + idea_id, path, stack, created_at: now.clone(), updated_at: now, }; - state - .projects - .insert(record.clone()) - .await - .map_err(err_str)?; + state.projects.insert(record.clone()).await.map_err(err_str)?; Ok(record) } @@ -111,7 +128,7 @@ pub struct ImportProjectInput { /// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。 /// /// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description -/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 拼记录 insert → 返回。 +/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 走 create_with_binding insert → 返回。 #[tauri::command] pub async fn import_project( state: State<'_, AppState>, @@ -124,18 +141,15 @@ pub async fn import_project( if !Path::new(&path).is_dir() { return Err(format!("目录不存在: {path}")); } - // 防重复绑定(normalize_path 规范化比较,防正反斜杠/末尾斜杠绕过) - if let Some(conflict) = find_binding_conflict(&state, &path, None).await? { - return Err(format!("目录已被项目「{}」绑定", conflict.name)); - } - // 探测栈 + 读 description(spawn_blocking 防 IO 阻塞 tokio runtime) + // 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。 + // spawn_blocking 防 IO 阻塞 tokio runtime。stack 解析后透传给 create_with_binding + // (不再重复探测,与原行为一致)。 let root = std::path::PathBuf::from(&path); let want_name = input.name.clone(); let want_desc = input.description.clone(); let want_stack = input.stack.clone(); let (name, description, stack_json) = tokio::task::spawn_blocking(move || -> Result<_, String> { - // name: 入参优先,否则取目录名 let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(n) => n.to_string(), None => root @@ -144,12 +158,10 @@ pub async fn import_project( .map(|s| s.to_string()) .ok_or_else(|| "无法从路径解析项目名".to_string())?, }; - // description: 入参优先,否则读 README 首段 let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => extract_description(&root).unwrap_or_default(), }; - // stack: 入参优先,否则自动探测 let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(s) => s.to_string(), None => { @@ -162,24 +174,7 @@ pub async fn import_project( .await .map_err(err_str)??; - let now = now_millis(); - let record = ProjectRecord { - id: new_id(), - name, - description, - status: "planning".to_string(), - idea_id: None, - path: Some(path), - stack: Some(stack_json), - created_at: now.clone(), - updated_at: now, - }; - state - .projects - .insert(record.clone()) - .await - .map_err(err_str)?; - Ok(record) + create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await } /// 按 ID 查询项目 @@ -325,6 +320,228 @@ pub async fn check_path_exists(path: String) -> Result { Ok(Path::new(&path).is_dir()) } +// ============================================================ +// 批量扫描/导入历史项目 — F-260614-06(scan 第二步) +// ============================================================ + +/// 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。 +#[derive(Debug, Serialize)] +pub struct ScannedProjectItem { + pub path: String, + pub name: String, + pub stack: Vec, + pub is_monorepo: bool, + /// 该目录是否已被某个项目绑定(防重复,前端标记禁选) + pub already_bound: bool, +} + +/// 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。 +/// +/// 调 `discover_projects`(monorepo 一层展开 + detect_stack 非空过滤), +/// 标记每个候选是否已被项目绑定。前端用预览表格勾选后调 import_projects_batch。 +#[tauri::command] +pub async fn scan_directory_for_projects( + state: State<'_, AppState>, + root_path: String, +) -> Result, String> { + let root = Path::new(&root_path); + if !root.is_dir() { + return Err(format!("目录不存在: {root_path}")); + } + + // 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime) + let scan_root = std::path::PathBuf::from(&root_path); + let discovered: Vec = tokio::task::spawn_blocking(move || { + discover_projects(&scan_root) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + // 2. 标已绑定项(逐项 normalize_path 查重) + let mut out = Vec::with_capacity(discovered.len()); + for d in discovered { + let already_bound = find_binding_conflict(&state, &d.path, None) + .await? + .is_some(); + out.push(ScannedProjectItem { + path: d.path, + name: d.name, + stack: d.stack, + is_monorepo: d.is_monorepo, + already_bound, + }); + } + Ok(out) +} + +/// 批量导入历史项目单条结果 +#[derive(Debug, Serialize)] +pub struct ImportBatchItemResult { + /// 入参 path(回显,前端按 path 对齐结果) + pub path: String, + /// 成功:导入的项目名;失败:None + pub name: Option, + /// 失败原因(成功为 None) + pub error: Option, +} + +/// 批量导入历史项目结果(前端 toast 汇总) +#[derive(Debug, Serialize)] +pub struct ImportBatchResult { + pub imported: usize, + pub skipped: usize, + pub items: Vec, +} + +/// 单条批量导入入参 +#[derive(Debug, Deserialize)] +pub struct ImportBatchItemInput { + pub path: String, + #[serde(default)] + pub name: Option, +} + +/// 批量导入历史项目 — 对用户勾选项并发 LLM 抽 description + 入库绑定。 +/// +/// F-260614-06 决策⑤:扫描(scan_directory_for_projects)纯规则发现;此命令对勾选项 +/// 并发跑 LLM(复用 scan_project_with_ai 的 complete 调用)抽 description。每项独立, +/// 非原子 —— 单项失败不影响其它项,逐项结果回传。LLM 全失败 description 留空(不喂噪音), +/// 用户可在详情页手填。 +/// +/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。 +/// 默认 planning 状态(对齐 create_project),不关联 idea。 +#[tauri::command] +pub async fn import_projects_batch( + state: State<'_, AppState>, + items: Vec, +) -> Result { + if items.is_empty() { + return Ok(ImportBatchResult { + imported: 0, + skipped: 0, + items: Vec::new(), + }); + } + + // 取默认 provider(优先 is_default,否则首个)。无 provider 直接报错(批量无降级路径, + // 因为 description 是核心目的,无 LLM 与单 import_project 行为不同 —— 那走 import_project) + let providers = state.ai_providers.list_all().await.map_err(err_str)?; + let pc = providers + .iter() + .find(|p| p.is_default) + .cloned() + .or_else(|| providers.into_iter().next()) + .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?; + // build_provider_for 返回 Box(非 Clone);多 future 共享需 Arc 包装。 + // LlmProvider: Send + Sync + complete(&self) → Arc 共享安全。 + let boxed = crate::commands::ai::secret::build_provider_for(&pc) + .map_err(|e| format!("provider 密钥不可用: {e}"))?; + let provider: std::sync::Arc = std::sync::Arc::from(boxed); + + // 每项独立 future,并发 join。失败逐项记录不影响其它。 + // 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。 + let futures: Vec<_> = items + .into_iter() + .map(|item| { + let state_ref = state.inner(); + let provider = provider.clone(); + let pc = pc.clone(); + async move { + let path = item.path.trim().to_string(); + if path.is_empty() { + return ImportBatchItemResult { + path, + name: None, + error: Some("路径为空".to_string()), + }; + } + // 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理) + let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await { + Ok(d) => d, + Err(e) => { + // LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。 + tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}"); + String::new() + } + }; + let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from); + match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await { + Ok(rec) => ImportBatchItemResult { + path, + name: Some(rec.name), + error: None, + }, + Err(e) => ImportBatchItemResult { + path, + name: None, + error: Some(e), + }, + } + } + }) + .collect(); + + let results = futures::future::join_all(futures).await; + let imported = results.iter().filter(|r| r.name.is_some()).count(); + let skipped = results.len() - imported; + Ok(ImportBatchResult { + imported, + skipped, + items: results, + }) +} + +/// 名字解析:入参优先,否则取目录名 +fn resolve_name(path: &str, want: Option) -> String { + if let Some(n) = want { + return n; + } + Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.to_string()) +} + +/// 复用 scan_project_with_ai 路径抽 description(轻量子代理)。 +/// 双层 llm_concurrency permit 限流 + LLM 失败/解析失败返回空 description(不报错)。 +async fn extract_description_via_llm( + state: &AppState, + provider: &std::sync::Arc, + pc: &df_storage::models::AiProviderRecord, + path: &str, +) -> Result { + let root = std::path::PathBuf::from(path); + let (rule_stack, sample) = tokio::task::spawn_blocking(move || { + let stack = detect_stack(&root)?; + let sample = collect_sample(&root)?; + Ok::<_, anyhow::Error>((stack, sample)) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + + let request = CompletionRequest { + model: pc.default_model.clone(), + messages: build_scan_prompt(&sample, &rule_stack), + temperature: Some(0.2), + max_tokens: Some(400), + stream: false, + tools: None, + tool_choice: None, + }; + + let _g = state.llm_concurrency.acquire_global().await; + let _c = state.llm_concurrency.acquire_per_conv().await; + let resp = provider.complete(request).await.map_err(err_str)?; + // 只取 description,其它字段丢弃(批量场景不需要 project_type/stack 细化) + let desc = parse_scan_result(&resp.text) + .map(|p| p.description) + .unwrap_or_default(); + Ok(desc) +} + // ============================================================ // AI 扫描项目 — LLM 分析采样自动填基础信息 // ============================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f82aa39..aa073ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,6 +82,8 @@ pub fn run() { commands::project::relocate_project_path, commands::project::check_path_exists, commands::project::scan_project_with_ai, + commands::project::scan_directory_for_projects, + commands::project::import_projects_batch, // 任务 commands::task::list_tasks, commands::task::create_task, diff --git a/src/api/project.ts b/src/api/project.ts index 9ae140e..b217b37 100644 --- a/src/api/project.ts +++ b/src/api/project.ts @@ -13,6 +13,39 @@ export interface ImportProjectInput { stack?: string } +/** 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。 */ +export interface ScannedProjectItem { + path: string + name: string + stack: string[] + is_monorepo: boolean + /** 该目录是否已被某项目绑定(防重复,前端标记禁选) */ + already_bound: boolean +} + +/** 批量导入单条入参 */ +export interface ImportBatchItemInput { + path: string + /** 项目名(可选,空=用目录名) */ + name?: string +} + +/** 批量导入单条结果 */ +export interface ImportBatchItemResult { + path: string + /** 成功:导入的项目名;失败:null */ + name: string | null + /** 失败原因(成功为 null) */ + error: string | null +} + +/** 批量导入结果(前端 toast 汇总) */ +export interface ImportBatchResult { + imported: number + skipped: number + items: ImportBatchItemResult[] +} + export const projectApi = { list(): Promise { return invoke('list_projects') @@ -75,4 +108,20 @@ export const projectApi = { scanWithAi(path: string): Promise { return invoke('scan_project_with_ai', { path }) }, + + /** + * 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。 + * 返回含 monorepo 子项目展开结果,前端预览表格勾选后调 importProjectsBatch。 + */ + scanDirectoryForProjects(rootPath: string): Promise { + return invoke('scan_directory_for_projects', { rootPath }) + }, + + /** + * 批量导入历史项目 — 对勾选项并发 LLM 抽 description + 入库绑定。 + * 每项独立非原子,逐项结果回传。LLM 失败 description 留空(不喂噪音)。 + */ + importProjectsBatch(items: ImportBatchItemInput[]): Promise { + return invoke('import_projects_batch', { items }) + }, } diff --git a/src/i18n/en/projects.ts b/src/i18n/en/projects.ts index b9c1ca8..1934ac5 100644 --- a/src/i18n/en/projects.ts +++ b/src/i18n/en/projects.ts @@ -36,6 +36,26 @@ export default { aiScanNoDesc: 'AI could not generate a description, please fill manually', aiScanFailed: 'AI scan failed', dirConflict: 'This directory is already bound to project "{name}"', + // Import historical projects (F-260614-06 scan step 2) + importHistory: '📥 Import Projects', + importTitle: 'Import Historical Projects', + importSelectRoot: 'Select root directory to scan', + importScanning: 'Scanning…', + importRescan: 'Rescan', + importEmpty: 'No candidate projects found in this directory (need Cargo.toml/package.json/go.mod etc.)', + importColName: 'Name', + importColPath: 'Path', + importColStack: 'Stack', + importColBound: 'Bound', + importMonoHint: 'monorepo', + importSelectAll: 'Select all', + importSelected: '{n} selected', + importRun: 'Import selected', + importRunning: 'Importing…', + importDone: 'Import complete: {imported} succeeded, {skipped} skipped', + importNoSelection: 'Please select projects to import first', + importScanFailed: 'Failed to scan directory', + importFailed: 'Batch import failed', // Status labels (PROJECT_STATUS_LABELS values in constants/project.ts use these keys) status: { planning: '📐 Planning', diff --git a/src/i18n/zh-CN/projects.ts b/src/i18n/zh-CN/projects.ts index b878924..62ad25f 100644 --- a/src/i18n/zh-CN/projects.ts +++ b/src/i18n/zh-CN/projects.ts @@ -22,6 +22,26 @@ export default { aiScanNoDesc: 'AI 未能生成描述,可手动填写', aiScanFailed: 'AI 扫描失败', dirConflict: '该目录已被项目「{name}」绑定', + // 导入历史项目(F-260614-06 scan 第二步) + importHistory: '📥 导入历史项目', + importTitle: '导入历史项目', + importSelectRoot: '选择根目录扫描', + importScanning: '扫描中…', + importRescan: '重新扫描', + importEmpty: '该目录下未发现候选项目(需要有 Cargo.toml/package.json/go.mod 等标志文件)', + importColName: '项目名', + importColPath: '路径', + importColStack: '技术栈', + importColBound: '已绑定', + importMonoHint: 'monorepo', + importSelectAll: '全选', + importSelected: '已选 {n} 项', + importRun: '导入选中项', + importRunning: '导入中…', + importDone: '导入完成:成功 {imported} 项,跳过 {skipped} 项', + importNoSelection: '请先勾选要导入的项目', + importScanFailed: '扫描目录失败', + importFailed: '批量导入失败', // 回收站模态框 trashTitle: '🗑 回收站', trashEmpty: '回收站为空', diff --git a/src/views/Projects.vue b/src/views/Projects.vue index 033323c..9ab0f77 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -3,6 +3,7 @@