重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描
This commit is contained in:
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -750,6 +750,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"df-ai-core",
|
||||||
"df-core",
|
"df-core",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -760,6 +761,17 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "df-ai-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"futures",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "df-core"
|
name = "df-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -789,7 +801,9 @@ name = "df-ideas"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"df-ai-core",
|
||||||
"df-core",
|
"df-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
11
crates/df-ai-core/Cargo.toml
Normal file
11
crates/df-ai-core/Cargo.toml
Normal 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"
|
||||||
10
crates/df-ai-core/src/lib.rs
Normal file
10
crates/df-ai-core/src/lib.rs
Normal 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::*;
|
||||||
235
crates/df-ai-core/src/provider.rs
Normal file
235
crates/df-ai-core/src/provider.rs
Normal file
@@ -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<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,
|
||||||
|
/// 工具调用 ID(role=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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
df-core = { path = "../df-core" }
|
df-core = { path = "../df-core" }
|
||||||
|
df-ai-core = { path = "../df-ai-core" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "time"] }
|
tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub mod retry;
|
|||||||
|
|
||||||
use provider::LlmProvider;
|
use provider::LlmProvider;
|
||||||
|
|
||||||
|
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||||
|
pub use df_ai_core;
|
||||||
|
|
||||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||||
///
|
///
|
||||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||||
|
|||||||
@@ -1,231 +1,15 @@
|
|||||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
//! LLM Provider trait + 数据结构 re-export
|
||||||
//!
|
//!
|
||||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
|
||||||
//! 含 function calling / tool use 能力。
|
//! 如 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;
|
pub use df_ai_core::provider::*;
|
||||||
|
|
||||||
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,
|
|
||||||
/// 工具调用 ID(role=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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
df-core = { path = "../df-core" }
|
df-core = { path = "../df-core" }
|
||||||
|
df-ai-core = { path = "../df-ai-core" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
|
//! 对抗式评估系统 — 正反方辩论 + 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 anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use df_ai_core::provider::LlmProvider;
|
||||||
use df_core::types::{IdeaId, Priority};
|
use df_core::types::{IdeaId, Priority};
|
||||||
use crate::capture::Idea;
|
use crate::capture::Idea;
|
||||||
use crate::scoring::IdeaScores;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AdversarialEval {
|
pub struct AdversarialEval {
|
||||||
@@ -19,6 +44,9 @@ pub struct AdversarialEval {
|
|||||||
pub analyst: AnalystAnalysis,
|
pub analyst: AnalystAnalysis,
|
||||||
pub final_score: f64,
|
pub final_score: f64,
|
||||||
pub recommendation: Recommendation,
|
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 {
|
impl AdversarialEngine {
|
||||||
/// 执行完整的对抗评估
|
/// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式)
|
||||||
#[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用
|
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
|
||||||
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
|
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 scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||||
|
|
||||||
let positive = Self::generate_positive_argument(idea, &scores)?;
|
let positive = self.generate_positive_argument(idea, &scores)?;
|
||||||
let negative = Self::generate_negative_argument(idea, &scores)?;
|
let negative = self.generate_negative_argument(idea, &scores)?;
|
||||||
let analyst = Self::analyst_analysis(idea, &scores)?;
|
let analyst = self.analyst_analysis(idea, &scores)?;
|
||||||
let recommendation = Self::recommendation_for(&analyst.final_assessment);
|
let recommendation = self.recommendation_for(&analyst.final_assessment);
|
||||||
|
|
||||||
Ok(AdversarialEval {
|
Ok(AdversarialEval {
|
||||||
idea_id: idea.id.clone(),
|
idea_id: idea.id.clone(),
|
||||||
@@ -83,12 +156,14 @@ impl AdversarialEngine {
|
|||||||
analyst,
|
analyst,
|
||||||
final_score: scores.overall,
|
final_score: scores.overall,
|
||||||
recommendation,
|
recommendation,
|
||||||
|
// 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback)
|
||||||
|
evaluated_by: EvaluatedBy::Heuristic,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
||||||
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
|
/// 注:返回 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 desc = idea.description.trim();
|
||||||
let mut evidence = Vec::new();
|
let mut evidence = Vec::new();
|
||||||
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
|
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
|
||||||
@@ -126,7 +201,7 @@ impl AdversarialEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
|
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,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 desc = idea.description.trim();
|
||||||
let mut evidence = Vec::new();
|
let mut evidence = Vec::new();
|
||||||
if desc.is_empty() {
|
if desc.is_empty() {
|
||||||
@@ -166,7 +241,7 @@ impl AdversarialEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
/// 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 {
|
let final_assessment = match scores.overall {
|
||||||
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
||||||
x if x >= 6.0 => AssessmentLevel::Recommended,
|
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 {
|
match level {
|
||||||
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
||||||
AssessmentLevel::Recommended => Recommendation::Soon,
|
AssessmentLevel::Recommended => Recommendation::Soon,
|
||||||
@@ -302,7 +377,7 @@ mod tests {
|
|||||||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
|
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
|
||||||
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||||||
let scores = ScoringEngine::compute_default(&idea);
|
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!("\n[a1] 高分想法 → 期望 ImmediateAction");
|
||||||
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
|
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);
|
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
|
||||||
@@ -316,7 +391,7 @@ mod tests {
|
|||||||
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
|
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
|
||||||
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
|
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
|
||||||
let scores = ScoringEngine::compute_default(&idea);
|
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!("\n[a2] 中分想法 → 期望 Soon");
|
||||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
||||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||||
@@ -327,7 +402,7 @@ mod tests {
|
|||||||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
|
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
|
||||||
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
|
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
|
||||||
let scores = ScoringEngine::compute_default(&idea);
|
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!("\n[a3] 低分想法 → 期望 Monitor");
|
||||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
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);
|
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
|
||||||
@@ -337,7 +412,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a4_confidence_ranges() {
|
async fn a4_confidence_ranges() {
|
||||||
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
|
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!("\n[a4] confidence 区间校验");
|
||||||
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.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);
|
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
|
||||||
@@ -347,7 +422,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a5_positive_thesis_contains_title() {
|
async fn a5_positive_thesis_contains_title() {
|
||||||
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
|
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!("\n[a5] 正方论点含标题");
|
||||||
println!(" thesis: {}", eval.positive.thesis);
|
println!(" thesis: {}", eval.positive.thesis);
|
||||||
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
|
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
|
||||||
@@ -356,7 +431,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a6_negative_evidence_nonempty() {
|
async fn a6_negative_evidence_nonempty() {
|
||||||
let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]);
|
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());
|
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
|
||||||
for (i, e) in eval.negative.evidence.iter().enumerate() {
|
for (i, e) in eval.negative.evidence.iter().enumerate() {
|
||||||
println!(" 证据{}: {}", i + 1, e);
|
println!(" 证据{}: {}", i + 1, e);
|
||||||
@@ -369,7 +444,7 @@ mod tests {
|
|||||||
let desc = "面向用户的核心功能".to_string();
|
let desc = "面向用户的核心功能".to_string();
|
||||||
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
|
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
|
||||||
let scores = ScoringEngine::compute_default(&idea);
|
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!("\n[a7] final_score == scores.overall 一致性");
|
||||||
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
|
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
|
||||||
println!(" analyst.summary: {}", eval.analyst.summary);
|
println!(" analyst.summary: {}", eval.analyst.summary);
|
||||||
|
|||||||
@@ -92,6 +92,157 @@ pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
|||||||
Ok(stack)
|
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 的包名
|
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||||
let content = std::fs::read_to_string(path.as_ref())
|
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 行等噪声,
|
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||||
pub fn extract_description(root: &Path) -> Option<String> {
|
pub fn extract_description(root: &Path) -> Option<String> {
|
||||||
// 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||||
let content = read_readme(root)?;
|
let content = read_readme_raw(root)?;
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut started = false;
|
let mut started = false;
|
||||||
for raw_line in content.lines() {
|
for raw_line in content.lines() {
|
||||||
@@ -171,16 +322,28 @@ pub fn extract_description(root: &Path) -> Option<String> {
|
|||||||
/// extract_description 最大字符数
|
/// extract_description 最大字符数
|
||||||
const EXTRACT_DESC_MAX: usize = 200;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProjectSample {
|
pub struct ProjectSample {
|
||||||
pub readme: Option<String>,
|
pub readme: Option<String>,
|
||||||
pub tree: Vec<String>,
|
pub tree: Vec<String>,
|
||||||
/// (文件名, 截断内容)
|
/// (文件名, 截断内容)
|
||||||
pub manifests: Vec<(String, 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_MANIFEST_MAX: usize = 1500;
|
||||||
const SAMPLE_TREE_MAX: usize = 80;
|
const SAMPLE_TREE_MAX: usize = 80;
|
||||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||||
@@ -190,30 +353,260 @@ const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
|||||||
".turbo", ".angular", ".gradle", "vendor",
|
".turbo", ".angular", ".gradle", "vendor",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
|
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
anyhow::bail!("路径不是目录: {}", root.display());
|
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 {
|
Ok(ProjectSample {
|
||||||
readme: read_readme(root),
|
readme,
|
||||||
tree: collect_tree(root),
|
tree: collect_tree(root),
|
||||||
manifests: collect_manifests(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"] {
|
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||||
let p = root.join(name);
|
let p = root.join(name);
|
||||||
if p.is_file() {
|
if p.is_file() {
|
||||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||||
return Some(truncate_chars(&content, SAMPLE_README_MAX));
|
return Some(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
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 图片 `` 是否为纯徽章(架构图/截图等保留)
|
||||||
|
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();
|
||||||
|
// 匹配  —— 简单行级扫描,够用且无 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,无其它实质文本)。
|
||||||
|
/// 如 `  <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(".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> {
|
fn collect_tree(root: &Path) -> Vec<String> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
@@ -344,14 +737,25 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn collects_sample() {
|
fn collects_sample() {
|
||||||
let d = scratch("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\n\n\n\nA test project.\nMore.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||||
fs::create_dir(d.join("src")).unwrap();
|
fs::create_dir(d.join("src")).unwrap();
|
||||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||||
fs::create_dir(d.join("node_modules")).unwrap();
|
fs::create_dir(d.join("node_modules")).unwrap();
|
||||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||||
let s = collect_sample(&d).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.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||||
// node_modules 应被过滤
|
// node_modules 应被过滤
|
||||||
@@ -359,6 +763,103 @@ mod tests {
|
|||||||
fs::remove_dir_all(&d).ok();
|
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\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 = "\n\n\n\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]
|
#[test]
|
||||||
fn extract_desc_skips_title_badge() {
|
fn extract_desc_skips_title_badge() {
|
||||||
let d = scratch("desc");
|
let d = scratch("desc");
|
||||||
|
|||||||
@@ -575,10 +575,10 @@
|
|||||||
- [x] T-260614-04 — ~~路径校验根治~~ ✅ 已完成(resolve_workspace_path 加 canonicalize 防 symlink 逃逸 + 词法 starts_with 兜底;仅校验、返回词法路径保持前端友好;cargo check 0 err / 22 test pass)(06-14)
|
- [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-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-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-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-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)
|
- [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<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-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-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
|
- [x] ⏸️(待决策.md已决c暂缓·2026-06-16) F-260614-10 — 知识库 MCP Server + Tier 2/3 — 对外 MCP 暴露 + 分层存储(当前仅 Tier 1 全栈) — source:PROGRESS Sprint15
|
||||||
|
|||||||
28
docs/待审查.md
28
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<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 复用)。
|
- **范围**: 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) 暴露面。
|
- **维度**: ①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 累计计数(跨审批不重置) — ⏳ 待审
|
### 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 模式一致)。
|
- **维度**: ①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 合批)。
|
- **commit**: d00b30f(同 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)。
|
- **主代独立核查**: ✅ 全过(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 待复审重点**: ①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 未碰)确认无冲突。
|
- **审查 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<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 汇总交互完整。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! 灵感相关命令
|
//! 灵感相关命令
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
use df_ai::provider::LlmProvider;
|
||||||
use df_core::types::{new_id, Priority};
|
use df_core::types::{new_id, Priority};
|
||||||
use df_ideas::capture::Idea;
|
use df_ideas::capture::Idea;
|
||||||
use df_storage::models::{IdeaRecord, ProjectRecord};
|
use df_storage::models::{IdeaRecord, ProjectRecord};
|
||||||
@@ -183,10 +186,13 @@ pub async fn evaluate_idea(
|
|||||||
// 多维评分(0-10,IPC 层 *10 缩放为 0-100)
|
// 多维评分(0-10,IPC 层 *10 缩放为 0-100)
|
||||||
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
|
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
|
||||||
|
|
||||||
// 对抗式评估
|
// 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM,无 provider/构造失败 → 启发式兜底)
|
||||||
let eval = df_ideas::adversarial::AdversarialEngine::evaluate(&idea)
|
let provider = build_default_provider(&state).await;
|
||||||
.await
|
let engine = match provider {
|
||||||
.map_err(err_str)?;
|
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 对齐)
|
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
|
||||||
let positive_strength = eval.positive.confidence;
|
let positive_strength = eval.positive.confidence;
|
||||||
@@ -248,6 +254,31 @@ pub async fn evaluate_idea(
|
|||||||
Ok(updated)
|
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 不影响评分)
|
/// IdeaRecord → df_ideas::Idea(评估用,status/time 不影响评分)
|
||||||
fn record_to_idea(record: &IdeaRecord) -> Idea {
|
fn record_to_idea(record: &IdeaRecord) -> Idea {
|
||||||
let tags: Vec<String> = record
|
let tags: Vec<String> = record
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use tauri::State;
|
|||||||
|
|
||||||
use df_ai::provider::{ChatMessage, CompletionRequest};
|
use df_ai::provider::{ChatMessage, CompletionRequest};
|
||||||
use df_core::types::new_id;
|
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 df_storage::models::ProjectRecord;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -42,18 +45,36 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectReco
|
|||||||
pub async fn create_project(
|
pub async fn create_project(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
input: CreateProjectInput,
|
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> {
|
) -> 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) => {
|
Some(p) => {
|
||||||
if !Path::new(p).is_dir() {
|
if !Path::new(p).is_dir() {
|
||||||
return Err(format!("目录不存在: {p}"));
|
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));
|
return Err(format!("目录已被项目「{}」绑定", conflict.name));
|
||||||
}
|
}
|
||||||
// stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
|
// 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(),
|
Some(s) => s.to_string(),
|
||||||
None => {
|
None => {
|
||||||
let root = std::path::PathBuf::from(p);
|
let root = std::path::PathBuf::from(p);
|
||||||
@@ -72,20 +93,16 @@ pub async fn create_project(
|
|||||||
let now = now_millis();
|
let now = now_millis();
|
||||||
let record = ProjectRecord {
|
let record = ProjectRecord {
|
||||||
id: new_id(),
|
id: new_id(),
|
||||||
name: input.name,
|
name,
|
||||||
description: input.description,
|
description,
|
||||||
status: "planning".to_string(),
|
status: "planning".to_string(),
|
||||||
idea_id: input.idea_id,
|
idea_id,
|
||||||
path,
|
path,
|
||||||
stack,
|
stack,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
state
|
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
||||||
.projects
|
|
||||||
.insert(record.clone())
|
|
||||||
.await
|
|
||||||
.map_err(err_str)?;
|
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +128,7 @@ pub struct ImportProjectInput {
|
|||||||
/// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。
|
/// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。
|
||||||
///
|
///
|
||||||
/// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description
|
/// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description
|
||||||
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 拼记录 insert → 返回。
|
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 走 create_with_binding insert → 返回。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_project(
|
pub async fn import_project(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -124,18 +141,15 @@ pub async fn import_project(
|
|||||||
if !Path::new(&path).is_dir() {
|
if !Path::new(&path).is_dir() {
|
||||||
return Err(format!("目录不存在: {path}"));
|
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 root = std::path::PathBuf::from(&path);
|
||||||
let want_name = input.name.clone();
|
let want_name = input.name.clone();
|
||||||
let want_desc = input.description.clone();
|
let want_desc = input.description.clone();
|
||||||
let want_stack = input.stack.clone();
|
let want_stack = input.stack.clone();
|
||||||
let (name, description, stack_json) = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
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()) {
|
let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
Some(n) => n.to_string(),
|
Some(n) => n.to_string(),
|
||||||
None => root
|
None => root
|
||||||
@@ -144,12 +158,10 @@ pub async fn import_project(
|
|||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.ok_or_else(|| "无法从路径解析项目名".to_string())?,
|
.ok_or_else(|| "无法从路径解析项目名".to_string())?,
|
||||||
};
|
};
|
||||||
// description: 入参优先,否则读 README 首段
|
|
||||||
let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
Some(d) => d.to_string(),
|
Some(d) => d.to_string(),
|
||||||
None => extract_description(&root).unwrap_or_default(),
|
None => extract_description(&root).unwrap_or_default(),
|
||||||
};
|
};
|
||||||
// stack: 入参优先,否则自动探测
|
|
||||||
let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
Some(s) => s.to_string(),
|
Some(s) => s.to_string(),
|
||||||
None => {
|
None => {
|
||||||
@@ -162,24 +174,7 @@ pub async fn import_project(
|
|||||||
.await
|
.await
|
||||||
.map_err(err_str)??;
|
.map_err(err_str)??;
|
||||||
|
|
||||||
let now = now_millis();
|
create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 ID 查询项目
|
/// 按 ID 查询项目
|
||||||
@@ -325,6 +320,228 @@ pub async fn check_path_exists(path: String) -> Result<bool, String> {
|
|||||||
Ok(Path::new(&path).is_dir())
|
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 分析采样自动填基础信息
|
// AI 扫描项目 — LLM 分析采样自动填基础信息
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ pub fn run() {
|
|||||||
commands::project::relocate_project_path,
|
commands::project::relocate_project_path,
|
||||||
commands::project::check_path_exists,
|
commands::project::check_path_exists,
|
||||||
commands::project::scan_project_with_ai,
|
commands::project::scan_project_with_ai,
|
||||||
|
commands::project::scan_directory_for_projects,
|
||||||
|
commands::project::import_projects_batch,
|
||||||
// 任务
|
// 任务
|
||||||
commands::task::list_tasks,
|
commands::task::list_tasks,
|
||||||
commands::task::create_task,
|
commands::task::create_task,
|
||||||
|
|||||||
@@ -13,6 +13,39 @@ export interface ImportProjectInput {
|
|||||||
stack?: string
|
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 = {
|
export const projectApi = {
|
||||||
list(): Promise<ProjectRecord[]> {
|
list(): Promise<ProjectRecord[]> {
|
||||||
return invoke('list_projects')
|
return invoke('list_projects')
|
||||||
@@ -75,4 +108,20 @@ export const projectApi = {
|
|||||||
scanWithAi(path: string): Promise<AiScanResult> {
|
scanWithAi(path: string): Promise<AiScanResult> {
|
||||||
return invoke('scan_project_with_ai', { path })
|
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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,26 @@ export default {
|
|||||||
aiScanNoDesc: 'AI could not generate a description, please fill manually',
|
aiScanNoDesc: 'AI could not generate a description, please fill manually',
|
||||||
aiScanFailed: 'AI scan failed',
|
aiScanFailed: 'AI scan failed',
|
||||||
dirConflict: 'This directory is already bound to project "{name}"',
|
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 labels (PROJECT_STATUS_LABELS values in constants/project.ts use these keys)
|
||||||
status: {
|
status: {
|
||||||
planning: '📐 Planning',
|
planning: '📐 Planning',
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ export default {
|
|||||||
aiScanNoDesc: 'AI 未能生成描述,可手动填写',
|
aiScanNoDesc: 'AI 未能生成描述,可手动填写',
|
||||||
aiScanFailed: 'AI 扫描失败',
|
aiScanFailed: 'AI 扫描失败',
|
||||||
dirConflict: '该目录已被项目「{name}」绑定',
|
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: '🗑 回收站',
|
trashTitle: '🗑 回收站',
|
||||||
trashEmpty: '回收站为空',
|
trashEmpty: '回收站为空',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ $t('projects.title') }}</h1>
|
<h1>{{ $t('projects.title') }}</h1>
|
||||||
<div class="header-actions">
|
<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-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
||||||
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +70,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-grid">
|
||||||
<div class="project-card" v-for="project in store.projects" :key="project.id" @click="router.push('/projects/' + project.id)">
|
<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) -->
|
<!-- 确认弹层(删除/彻底删除,替代原生 window.confirm) -->
|
||||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
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 ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import type { ProjectRecord } from '@/api/types'
|
import type { ProjectRecord } from '@/api/types'
|
||||||
|
import type { ScannedProjectItem } from '@/api/project'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
@@ -234,6 +301,122 @@ async function handlePurge(project: ProjectRecord) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadProjects()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -437,4 +620,35 @@ onMounted(() => {
|
|||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user