重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描

This commit is contained in:
2026-06-16 20:19:55 +08:00
parent d00b30f0ba
commit 2069f79198
19 changed files with 1518 additions and 311 deletions

14
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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::*;

View File

@@ -0,0 +1,235 @@
//! LLM Provider trait — 统一的 LLM 调用抽象
//!
//! 支持 OpenAI 兼容 API覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
//! 含 function calling / tool use 能力。
//!
//! 本文件仅含 trait + 数据结构定义(零 IO。HTTP implOpenAICompatProvider /
//! 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<ChatMessage>,
/// 温度0.0 ~ 2.0
pub temperature: Option<f32>,
/// 最大生成 token 数
pub max_tokens: Option<u32>,
/// 是否流式输出
pub stream: bool,
/// 可调用的工具定义
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>,
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
/// 聊天消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
/// 工具调用 IDrole=Tool 时必填)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// AI 发起的工具调用列表role=Assistant 时可能有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>, tool_calls: Vec<ToolCall>) -> 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<String>, content: impl Into<String>) -> 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<String>, description: impl Into<String>, 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<String>, name: impl Into<String>, arguments: impl Into<String>) -> 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<Vec<ToolCall>>,
}
/// 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<Vec<ToolCallDelta>>,
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
/// 非空表示流中途出错不应视为正常完成finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
pub error: Option<String>,
}
/// 工具调用增量(流式中的片段)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallDelta {
/// 索引
pub index: u32,
/// 工具调用 ID仅第一个 chunk 有)
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// 函数名片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
/// 函数参数片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_arguments: Option<String>,
}
/// 异步流类型别名
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
/// LLM Provider trait
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 同步调用
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
/// 流式调用(返回异步流)
async fn stream(
&self,
request: CompletionRequest,
) -> anyhow::Result<StreamResult>;
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
///
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
/// Anthropic 无 embedding API,保持默认。
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
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()
}
}

View File

@@ -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"] }

View File

@@ -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` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方),

View File

@@ -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<ChatMessage>,
/// 温度0.0 ~ 2.0
pub temperature: Option<f32>,
/// 最大生成 token 数
pub max_tokens: Option<u32>,
/// 是否流式输出
pub stream: bool,
/// 可调用的工具定义
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>,
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
}
/// 聊天消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
/// 工具调用 IDrole=Tool 时必填)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
/// AI 发起的工具调用列表role=Assistant 时可能有)
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
/// 生成该消息的 model仅 assistant 消息有,消息级 model 追溯)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 消息状态UX-09 编辑重生成None/"active" 正常可见;
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
/// 默认 None向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>, tool_calls: Vec<ToolCall>) -> 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<String>, content: impl Into<String>) -> 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<String>, description: impl Into<String>, 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<String>, name: impl Into<String>, arguments: impl Into<String>) -> 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<Vec<ToolCall>>,
}
/// 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<Vec<ToolCallDelta>>,
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
/// 非空表示流中途出错不应视为正常完成finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
pub error: Option<String>,
}
/// 工具调用增量(流式中的片段)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallDelta {
/// 索引
pub index: u32,
/// 工具调用 ID仅第一个 chunk 有)
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// 函数名片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
/// 函数参数片段
#[serde(skip_serializing_if = "Option::is_none")]
pub function_arguments: Option<String>,
}
/// 异步流类型别名
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
/// LLM Provider trait
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 同步调用
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
/// 流式调用(返回异步流)
async fn stream(
&self,
request: CompletionRequest,
) -> anyhow::Result<StreamResult>;
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
///
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
/// Anthropic 无 embedding API,保持默认。
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
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::*;

View File

@@ -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 }

View File

@@ -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<Arc<dyn LlmProvider>>,
}
impl AdversarialEngine {
/// 执行完整的对抗评估
#[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
/// 注入 LLM provider 构造provider Some 时走 LLM调用失败自动降级启发式
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
Self { provider: Some(provider) }
}
/// 纯启发式构造(无 LLM 配置时的默认模式)
pub fn heuristic() -> Self {
Self { provider: None }
}
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
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<dyn LlmProvider>) -> Result<AdversarialEval> {
anyhow::bail!("LLM 对抗评估尚未实现(F-260614-03)")
}
/// 启发式评估(基于评分与内容信号,稳定有区分度)
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
// 先做多维评分,作为正反方论点与置信度的依据
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<Argument> {
fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
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<Argument> {
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
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<AnalystAnalysis> {
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
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);

View File

@@ -92,6 +92,157 @@ pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
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::<serde_json::Value>(&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<String>,
/// 是否为 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<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = 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<Path>) -> Result<Vec<String>> {
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<String> {
// 复用 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<String> {
/// 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<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
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<ProjectSample> {
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<String> {
/// 读 README 原始内容(不做处理)
fn read_readme_raw(root: &Path) -> Option<String> {
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<ImageRef> {
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("<!--") {
if trimmed.contains("-->") {
// 单行注释,整行跳过
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](...) <a href="..."><img.../></a>`
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 {
// 找下一个 ![ 或 <img 或 <a
let img_md = rest.find("![");
let img_html = rest.to_lowercase().find("<img");
let anchor_html = rest.to_lowercase().find("<a ");
let earliest = [img_md, img_html, anchor_html]
.into_iter()
.flatten()
.min();
let Some(pos) = earliest else {
// 剩余纯文本
text_only.push_str(rest);
break;
};
// pos 之前的文本进 text_only
text_only.push_str(&rest[..pos]);
let suffix = &rest[pos..];
let consumed = if suffix.starts_with("![") {
// markdown 图片:吃到 )
if let Some(end) = suffix.find(')') {
let alt = &suffix[2..suffix.find("](").unwrap_or(end)];
let src = &suffix[suffix.find("](").map(|i| i + 2).unwrap_or(end)..end];
if !is_badge_image(alt, src) {
// 内容图混在行里 → 非纯徽章行
return false;
}
content_found = true;
end + 1
} else {
// 不闭合,当普通文本
return false;
}
} else {
// HTML <img 或 <a 标签:视为徽章载体(HTML 行常见于此)
// 找 > 闭合
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::<String>();
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<String> {
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<!-- hidden comment -->\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");

View File

@@ -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<ContentPart>(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<ImageRef{alt,src}>`(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-05monorepo 一层(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_descendants1 行,签名兼容)(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 下沉独立 cratedf-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<dyn LlmProvider>)+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<Box>→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

View File

@@ -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<String>`;`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 falseAiMaxRoundsReached(防无限审批烧 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<Arc<dyn LlmProvider>>`(:96) + `new(provider:Arc<dyn LlmProvider>)`(: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<Box<dyn LlmProvider>>: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<Arc>——调用方分支等价性,无遗漏 Some/None) ④EvaluatedBy 三态正确(Llm/Heuristic/HeuristicFallback,provider None 全走 Heuristic) ⑤Box→Arc 转换(idea.rs:192 Arc::from(Box<dyn LlmProvider>) 编译通过) ⑥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<ImageRef{alt,src}>` 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 汇总交互完整。
---

View File

@@ -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-10IPC 层 *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<Box<dyn LlmProvider>> {
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<String> = record

View File

@@ -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<Vec<ProjectReco
pub async fn create_project(
state: State<'_, AppState>,
input: CreateProjectInput,
) -> Result<ProjectRecord, String> {
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<String>,
path: Option<String>,
stack: Option<String>,
) -> Result<ProjectRecord, String> {
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
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<bool, String> {
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<String>,
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<Vec<ScannedProjectItem>, 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<DiscoveredProject> = 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<String>,
/// 失败原因(成功为 None)
pub error: Option<String>,
}
/// 批量导入历史项目结果(前端 toast 汇总)
#[derive(Debug, Serialize)]
pub struct ImportBatchResult {
pub imported: usize,
pub skipped: usize,
pub items: Vec<ImportBatchItemResult>,
}
/// 单条批量导入入参
#[derive(Debug, Deserialize)]
pub struct ImportBatchItemInput {
pub path: String,
#[serde(default)]
pub name: Option<String>,
}
/// 批量导入历史项目 — 对用户勾选项并发 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<ImportBatchItemInput>,
) -> Result<ImportBatchResult, String> {
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<dyn LlmProvider>(非 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<dyn df_ai::provider::LlmProvider> = 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>) -> 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<dyn df_ai::provider::LlmProvider>,
pc: &df_storage::models::AiProviderRecord,
path: &str,
) -> Result<String, String> {
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 分析采样自动填基础信息
// ============================================================

View File

@@ -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,

View File

@@ -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<ProjectRecord[]> {
return invoke('list_projects')
@@ -75,4 +108,20 @@ export const projectApi = {
scanWithAi(path: string): Promise<AiScanResult> {
return invoke('scan_project_with_ai', { path })
},
/**
* 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。
* 返回含 monorepo 子项目展开结果,前端预览表格勾选后调 importProjectsBatch。
*/
scanDirectoryForProjects(rootPath: string): Promise<ScannedProjectItem[]> {
return invoke('scan_directory_for_projects', { rootPath })
},
/**
* 批量导入历史项目 — 对勾选项并发 LLM 抽 description + 入库绑定。
* 每项独立非原子,逐项结果回传。LLM 失败 description 留空(不喂噪音)。
*/
importProjectsBatch(items: ImportBatchItemInput[]): Promise<ImportBatchResult> {
return invoke('import_projects_batch', { items })
},
}

View File

@@ -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',

View File

@@ -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: '回收站为空',

View File

@@ -3,6 +3,7 @@
<header class="page-header">
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
</div>
@@ -69,6 +70,68 @@
</div>
</div>
<!-- 导入历史项目模态框(F-260614-06 scan 第二步) -->
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
<div class="modal-box import-box">
<h3 class="modal-title">{{ $t('projects.importTitle') }}</h3>
<!-- 选根目录 + 扫描 -->
<div class="modal-field">
<div class="dir-row">
<input v-model="importRootPath" :placeholder="$t('projects.importSelectRoot')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickImportRoot">{{ $t('projects.selectDir') }}</button>
</div>
</div>
<!-- 扫描结果表格(只读预览 + 勾选) -->
<div v-if="importScanning" class="import-status">{{ $t('projects.importScanning') }}</div>
<div v-else-if="importError" class="path-warning"> {{ importError }}</div>
<div v-else-if="scannedItems.length === 0 && importRootPath" class="import-status">{{ $t('projects.importEmpty') }}</div>
<div v-if="scannedItems.length" class="import-table-wrap">
<table class="import-table">
<thead>
<tr>
<th class="col-check">
<input type="checkbox" :checked="allImportSelected" :indeterminate.prop="someImportSelected" @change="toggleSelectAll(($event.target as HTMLInputElement).checked)" />
</th>
<th>{{ $t('projects.importColName') }}</th>
<th>{{ $t('projects.importColStack') }}</th>
<th>{{ $t('projects.importColPath') }}</th>
<th>{{ $t('projects.importColBound') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in scannedItems" :key="item.path" :class="{ 'row-disabled': item.already_bound }">
<td class="col-check">
<input type="checkbox" :disabled="item.already_bound" :checked="isImportSelected(item.path)" @change="toggleSelect(item.path, ($event.target as HTMLInputElement).checked)" />
</td>
<td>
<span class="imp-name">{{ item.name }}</span>
<span v-if="item.is_monorepo" class="mono-tag">{{ $t('projects.importMonoHint') }}</span>
</td>
<td>
<span class="tech-tag" v-for="s in item.stack" :key="s">{{ s }}</span>
</td>
<td class="col-path" :title="item.path">{{ item.path }}</td>
<td>
<span v-if="item.already_bound" class="bound-mark"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-actions">
<span class="selection-count" v-if="scannedItems.length">{{ $t('projects.importSelected', { n: selectedImportPaths.size }) }}</span>
<button class="btn btn-ghost" @click="closeImportModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="runImport" :disabled="importRunning || selectedImportPaths.size === 0">
{{ importRunning ? $t('projects.importRunning') : $t('projects.importRun') }}
</button>
</div>
</div>
</div>
<!-- 项目卡片网格 -->
<div class="project-grid">
<div class="project-card" v-for="project in store.projects" :key="project.id" @click="router.push('/projects/' + project.id)">
@@ -110,11 +173,14 @@
<!-- 确认弹层删除/彻底删除替代原生 window.confirm -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
<!-- 批量导入结果 toast -->
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
@@ -126,6 +192,7 @@ import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } fr
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import type { ProjectRecord } from '@/api/types'
import type { ScannedProjectItem } from '@/api/project'
const router = useRouter()
const store = useProjectStore()
@@ -234,6 +301,122 @@ async function handlePurge(project: ProjectRecord) {
onMounted(() => {
store.loadProjects()
})
// ── 导入历史项目(F-260614-06 scan 第二步) ──
const showImportModal = ref(false)
const importRootPath = ref('')
const scannedItems = ref<ScannedProjectItem[]>([])
const importScanning = ref(false)
const importError = ref('')
const importRunning = ref(false)
const selectedImportPaths = ref<Set<string>>(new Set())
const toast = ref({ visible: false, msg: '', type: 'info' as 'info' | 'error' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: 'info' | 'error' = 'info') {
toast.value = { visible: true, msg, type }
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.value.visible = false }, 4000)
}
const allImportSelected = computed(() =>
scannedItems.value.length > 0
&& scannedItems.value.filter(i => !i.already_bound).every(i => selectedImportPaths.value.has(i.path))
)
const someImportSelected = computed(() =>
selectedImportPaths.value.size > 0 && !allImportSelected.value
)
function isImportSelected(path: string) {
return selectedImportPaths.value.has(path)
}
function toggleSelect(path: string, checked: boolean) {
const next = new Set(selectedImportPaths.value)
if (checked) next.add(path)
else next.delete(path)
selectedImportPaths.value = next
}
function toggleSelectAll(checked: boolean) {
if (checked) {
selectedImportPaths.value = new Set(
scannedItems.value.filter(i => !i.already_bound).map(i => i.path)
)
} else {
selectedImportPaths.value = new Set()
}
}
function openImportModal() {
showImportModal.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
}
function closeImportModal() {
showImportModal.value = false
importRootPath.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
importError.value = ''
importScanning.value = false
}
async function pickImportRoot() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
importRootPath.value = selected as string
await runScan()
} catch (e) {
console.error('选择根目录失败:', e)
}
}
async function runScan() {
if (!importRootPath.value) return
importScanning.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
try {
scannedItems.value = await projectApi.scanDirectoryForProjects(importRootPath.value)
} catch (e: any) {
importError.value = e?.toString() ?? t('projects.importScanFailed')
} finally {
importScanning.value = false
}
}
async function runImport() {
if (selectedImportPaths.value.size === 0) {
showToast(t('projects.importNoSelection'), 'error')
return
}
importRunning.value = true
try {
const items = Array.from(selectedImportPaths.value).map(p => {
const found = scannedItems.value.find(i => i.path === p)
return { path: p, name: found?.name }
})
const result = await projectApi.importProjectsBatch(items)
// 刷新列表(批量入库后)
await store.loadProjects()
showToast(t('projects.importDone', { imported: result.imported, skipped: result.skipped }), result.imported > 0 ? 'info' : 'error')
if (result.imported > 0) {
closeImportModal()
} else {
// 全失败:保留弹窗 + 结果,让用户看失败项;重新扫描刷新已绑定态
await runScan()
}
} catch (e: any) {
showToast(e?.toString() ?? t('projects.importFailed'), 'error')
} finally {
importRunning.value = false
}
}
</script>
<style scoped>
@@ -437,4 +620,35 @@ onMounted(() => {
grid-template-columns: 1fr;
}
}
/* ===== 导入历史项目 ===== */
.import-box { width: 720px; max-height: 80vh; display: flex; flex-direction: column; }
.import-status { font-size: 13px; color: var(--df-text-dim); padding: 16px 0; text-align: center; }
.import-table-wrap { flex: 1; overflow: auto; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); margin-bottom: 8px; }
.import-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.import-table thead th {
position: sticky; top: 0; background: var(--df-bg);
text-align: left; padding: 8px 10px; font-weight: 500;
color: var(--df-text-dim); border-bottom: 0.5px solid var(--df-border);
}
.import-table tbody td { padding: 8px 10px; border-bottom: 0.5px solid var(--df-border); vertical-align: top; color: var(--df-text-secondary); }
.import-table tbody tr:last-child td { border-bottom: none; }
.import-table .col-check { width: 32px; text-align: center; }
.import-table .col-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--df-mono, monospace); font-size: 11px; }
.row-disabled { opacity: 0.5; }
.imp-name { font-weight: 500; color: var(--df-text); }
.mono-tag { margin-left: 6px; font-size: 10px; padding: 1px 6px; border-radius: var(--df-radius-xs); background: rgba(255,217,61,0.15); color: var(--df-warning); border: 0.5px solid var(--df-border); }
.bound-mark { color: var(--df-success); font-weight: 600; }
.selection-count { margin-right: auto; font-size: 12px; color: var(--df-text-dim); }
.import-box .modal-actions { align-items: center; }
/* ===== Toast ===== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 200; max-width: 80vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
</style>