Files
DevFlow/src-tauri/src/commands/idea.rs

350 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 灵感相关命令
use std::sync::Arc;
use serde::Deserialize;
use tauri::State;
use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority};
use df_ideas::capture::Idea;
use df_storage::models::{IdeaRecord, ProjectRecord};
use crate::state::AppState;
use super::{err_str, now_millis};
/// 创建灵感入参
#[derive(Debug, Deserialize)]
pub struct CreateIdeaInput {
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
/// 标签 JSON 数组字符串
pub tags: Option<String>,
pub source: Option<String>,
}
fn default_priority() -> i32 {
1
}
/// 列出灵感,可选按 status 过滤(指定状态走 query 走白名单索引列,否则全量)
#[tauri::command]
pub async fn list_ideas(
state: State<'_, AppState>,
status: Option<String>,
) -> Result<Vec<IdeaRecord>, String> {
match status {
Some(s) => state.ideas.query("status", &s).await.map_err(err_str),
None => state.ideas.list_all().await.map_err(err_str),
}
}
/// 创建灵感,返回完整记录
#[tauri::command]
pub async fn create_idea(
state: State<'_, AppState>,
input: CreateIdeaInput,
) -> Result<IdeaRecord, String> {
let now = now_millis();
let record = IdeaRecord {
id: new_id(),
title: input.title,
description: input.description,
status: "draft".to_string(),
priority: input.priority,
score: None,
tags: input.tags,
source: input.source,
promoted_to: None,
ai_analysis: None,
scores: None,
created_at: now.clone(),
updated_at: now,
};
state
.ideas
.insert(record.clone())
.await
.map_err(err_str)?;
Ok(record)
}
/// 更新灵感单个字段(字段名走 df-storage 白名单校验)
#[tauri::command]
pub async fn update_idea(
state: State<'_, AppState>,
id: String,
field: String,
value: String,
) -> Result<bool, String> {
state
.ideas
.update_field(&id, &field, &value)
.await
.map_err(err_str)
}
/// 删除灵感
#[tauri::command]
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.delete(&id).await.map_err(err_str)
}
/// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to
#[tauri::command]
pub async fn promote_idea(
state: State<'_, AppState>,
id: String,
) -> Result<df_ideas::promotion::PromotionResult, String> {
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("灵感不存在: {id}"))?;
if let Some(promoted_to) = &record.promoted_to {
return Err(format!("灵感已立项: {}", promoted_to));
}
// 复用 df-project 领域逻辑构造项目实体create_from_idea
let project = df_project::manager::ProjectManager::create_from_idea(
record.title.clone(),
record.description.clone(),
id.clone(),
);
let project_id = project.id.clone();
let now = now_millis();
let project_record = ProjectRecord {
id: project_id.clone(),
name: project.name,
description: project.description,
status: "planning".to_string(),
idea_id: Some(id.clone()),
path: None,
stack: None,
created_at: now.clone(),
updated_at: now.clone(),
};
state
.projects
.insert(project_record)
.await
.map_err(err_str)?;
// 回写灵感status=promoted + promoted_toupdate_full 单事务覆盖可变字段)
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord {
status: "promoted".to_string(),
promoted_to: Some(project_id.clone()),
updated_at: now,
..record
};
if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await {
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
}
Ok(df_ideas::promotion::PromotionResult {
idea_id: id,
project_id: project_id,
promoted: true,
reason: "手动立项".to_string(),
})
}
// ============================================================
// 灵感评估 — 多维评分 + 对抗式评估
// ============================================================
/// 评估灵感:多维评分 + 对抗式评估,结果写回 scores/score/ai_analysis状态置 pending_review返回更新后的记录
#[tauri::command]
pub async fn evaluate_idea(
state: State<'_, AppState>,
id: String,
) -> Result<IdeaRecord, String> {
// 取出灵感
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("灵感不存在: {id}"))?;
let idea = record_to_idea(&record);
// 多维评分0-10IPC 层 *10 缩放为 0-100
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
// 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM无 provider/构造失败 → 启发式兜底)
// F-01 阶段5: 透传 model_configs 池,evaluate_with_llm 经路由选模型(池空兜底 default_model)。
let provider = build_default_provider(&state).await;
let engine = match provider {
Some((p, pool)) => {
df_ideas::adversarial::AdversarialEngine::with_pool(Arc::from(p), pool)
}
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
};
let eval = engine.evaluate(&idea).await.map_err(err_str)?;
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
let positive_strength = eval.positive.confidence;
let negative_strength = eval.negative.confidence;
let net_sentiment = positive_strength - negative_strength;
let recommendation = recommendation_str(&eval.recommendation).to_string();
let final_score = eval.final_score;
let analyst_summary = eval.analyst.summary.clone();
let action_items = action_items_for(&eval.recommendation);
let positive = serde_json::json!({
"thesis": eval.positive.thesis,
"evidence": eval.positive.evidence,
});
let negative = serde_json::json!({
"thesis": eval.negative.thesis,
"evidence": eval.negative.evidence,
});
let ai_analysis = serde_json::json!({
"positive_strength": positive_strength,
"negative_strength": negative_strength,
"net_sentiment": net_sentiment,
"recommendation": recommendation,
"final_score": final_score,
"summary": analyst_summary,
"action_items": action_items,
"positive": positive,
"negative": negative,
"analyst": { "summary": analyst_summary },
})
.to_string();
// scores JSON中文维度 key + 0-100 值(前端雷达图直接当百分比用)
let scores_json = serde_json::json!({
"可行性": (scores.feasibility * 10.0).round() as i64,
"影响力": (scores.impact * 10.0).round() as i64,
"紧急度": (scores.urgency * 10.0).round() as i64,
"综合": (scores.overall * 10.0).round() as i64,
})
.to_string();
let score_value = (scores.overall * 10.0).round() as i64;
// 构造完整记录后单次原子写回update_full 保留 id 与 created_at
let updated = IdeaRecord {
scores: Some(scores_json),
ai_analysis: Some(ai_analysis),
score: Some(score_value as f64),
status: "pending_review".to_string(),
updated_at: now_millis(),
..record
};
state
.ideas
.update_full(&updated)
.await
.map_err(err_str)?;
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 密钥解析一致)。
///
/// 返回 (provider, model_pool):model_pool = 选中 provider 的 model_configs(F-01 阶段5,
/// 供对抗评估路由)。池空(用户未拉取)→ 调用方兜底 default_model。
async fn build_default_provider(
state: &State<'_, AppState>,
) -> Option<(Box<dyn LlmProvider>, Vec<df_ai::df_ai_core::model::ModelConfig>)> {
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, pc.model_configs.clone())),
Err(e) => {
// 密钥不可用:启发式兜底,不阻断评估(与 evaluate_idea LLM 失败降级语义一致)
tracing::warn!("默认 provider 密钥不可用,对抗评估走启发式: {e}");
None
}
}
}
/// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分)
fn record_to_idea(record: &IdeaRecord) -> Idea {
let tags: Vec<String> = match record.tags.as_deref() {
Some(t) => match serde_json::from_str::<Vec<String>>(t) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, idea_id = %record.id, "[ideas] tags JSON 解析失败,降级空 tags 继续评估");
Vec::new()
}
},
None => Vec::new(),
};
Idea {
id: record.id.clone(),
title: record.title.clone(),
description: record.description.clone(),
status: df_types::types::IdeaStatus::Draft,
priority: priority_from_i32(record.priority),
scores: None,
tags,
source: record.source.clone(),
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
/// i32 优先级 → Priority 枚举(与 df-types 枚举值一致Low=0/Medium=1/High=2/Critical=3
fn priority_from_i32(p: i32) -> Priority {
match p {
0 => Priority::Low,
2 => Priority::High,
x if x >= 3 => Priority::Critical,
_ => Priority::Medium,
}
}
/// Recommendation → 前端 assessmentLabel 期望的全小写空格分隔(匹配 map key
fn recommendation_str(r: &df_ideas::adversarial::Recommendation) -> &'static str {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => "immediate action",
Soon => "soon",
WithResources => "with resources",
ResearchMore => "research more",
Monitor => "monitor",
}
}
/// 行动建议 — 按推荐等级返回
fn action_items_for(r: &df_ideas::adversarial::Recommendation) -> Vec<String> {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => vec!["立即组建项目团队".into(), "制定详细执行计划".into(), "分配必要资源".into()],
Soon => vec!["下周启动项目".into(), "准备资源需求".into(), "制定时间表".into()],
WithResources => vec!["确认资源预算".into(), "评估 ROI".into(), "制定风险预案".into()],
ResearchMore => vec!["进行市场调研".into(), "收集用户反馈".into(), "验证技术可行性".into()],
Monitor => vec!["持续跟踪相关指标".into(), "定期评估进展".into(), "等待更好时机".into()],
}
}