重构: tool_registry拆分及多批改进
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -795,12 +795,9 @@ name = "df-execute"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
|
||||||
"df-types",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -886,7 +883,6 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -131,27 +131,6 @@ impl Default for AiToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 工具执行结果
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// 工具执行结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ToolExecutionResult {
|
|
||||||
/// 工具调用 ID
|
|
||||||
pub tool_call_id: String,
|
|
||||||
/// 工具名称
|
|
||||||
pub tool_name: String,
|
|
||||||
/// 执行参数
|
|
||||||
pub arguments: Value,
|
|
||||||
/// 执行结果
|
|
||||||
pub result: Value,
|
|
||||||
/// 是否需要人工批准
|
|
||||||
pub approval_required: bool,
|
|
||||||
/// 风险级别
|
|
||||||
pub risk_level: RiskLevel,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 辅助: 构建 JSON Schema 参数
|
// 辅助: 构建 JSON Schema 参数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -451,6 +451,107 @@ impl AnthropicCompatProvider {
|
|||||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||||
|
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||||
|
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||||
|
let lines: Vec<String> = messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
|
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||||||
|
let desc = match m.get("content") {
|
||||||
|
Some(serde_json::Value::String(s)) => format!("text({}B)", s.len()),
|
||||||
|
Some(serde_json::Value::Array(blocks)) => {
|
||||||
|
let parts: Vec<String> = blocks
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or("?");
|
||||||
|
match ty {
|
||||||
|
"text" => format!(
|
||||||
|
"text({}B)",
|
||||||
|
b.get("text").and_then(|t| t.as_str()).map(|s| s.len()).unwrap_or(0)
|
||||||
|
),
|
||||||
|
"tool_use" => format!(
|
||||||
|
"tool_use[id={},input_obj={}]",
|
||||||
|
b.get("id").and_then(|t| t.as_str()).unwrap_or(""),
|
||||||
|
b.get("input").map(|v| v.is_object()).unwrap_or(false)
|
||||||
|
),
|
||||||
|
"tool_result" => format!(
|
||||||
|
"tool_result[tid={}]",
|
||||||
|
b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("")
|
||||||
|
),
|
||||||
|
"image" => "image".to_string(),
|
||||||
|
_ => ty.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("[{}]", parts.join(","))
|
||||||
|
}
|
||||||
|
_ => "?".to_string(),
|
||||||
|
};
|
||||||
|
format!("#{}:{} {}", i, role, desc)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{} msgs: {}", lines.len(), lines.join(" | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||||
|
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
|
||||||
|
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
|
||||||
|
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
|
||||||
|
if messages.is_empty() {
|
||||||
|
return Err("messages 为空".into());
|
||||||
|
}
|
||||||
|
let first_role = messages[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
|
if first_role != "user" {
|
||||||
|
return Err(format!("首条 role={} 非法(须 user)", first_role));
|
||||||
|
}
|
||||||
|
for w in messages.windows(2) {
|
||||||
|
let r0 = w[0].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
|
let r1 = w[1].get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
|
if r0 == r1 && (r0 == "user" || r0 == "assistant") {
|
||||||
|
return Err(format!("连续同 role={}", r0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut tool_use_ids: Vec<&str> = Vec::new();
|
||||||
|
for (i, m) in messages.iter().enumerate() {
|
||||||
|
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
|
match m.get("content") {
|
||||||
|
Some(serde_json::Value::String(s)) if s.is_empty() && role == "user" => {
|
||||||
|
return Err(format!("#{} user content 空", i));
|
||||||
|
}
|
||||||
|
Some(serde_json::Value::Array(blocks)) => {
|
||||||
|
if blocks.is_empty() && role == "user" {
|
||||||
|
return Err(format!("#{} user content 空数组", i));
|
||||||
|
}
|
||||||
|
for b in blocks {
|
||||||
|
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||||
|
"tool_use" => {
|
||||||
|
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
tool_use_ids.push(id);
|
||||||
|
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||||
|
return Err(format!("#{} tool_use input 非 object", i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_result" => {
|
||||||
|
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||||
|
return Err(format!(
|
||||||
|
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||||
|
i, tid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 统一鉴权头:x-api-key + anthropic-version
|
/// 统一鉴权头:x-api-key + anthropic-version
|
||||||
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
rb.header("x-api-key", &self.api_key)
|
rb.header("x-api-key", &self.api_key)
|
||||||
@@ -466,6 +567,13 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
req.stream = false;
|
req.stream = false;
|
||||||
let body = self.convert_request(req);
|
let body = self.convert_request(req);
|
||||||
|
|
||||||
|
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||||
|
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||||
|
let summary = Self::summarize_messages(&body.messages);
|
||||||
|
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||||
|
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
|
||||||
|
}
|
||||||
|
|
||||||
debug!(model = %body.model, "Anthropic 同步调用");
|
debug!(model = %body.model, "Anthropic 同步调用");
|
||||||
|
|
||||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||||
@@ -504,7 +612,14 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
if is_reqwest_error_retryable(&e) {
|
if is_reqwest_error_retryable(&e) {
|
||||||
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
|
||||||
}
|
}
|
||||||
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
|
return AttemptOutcome::Fatal(format!(
|
||||||
|
"请求失败(不可重试): {} [timeout={} connect={} body={} src={:?}]",
|
||||||
|
e,
|
||||||
|
e.is_timeout(),
|
||||||
|
e.is_connect(),
|
||||||
|
e.is_body(),
|
||||||
|
std::error::Error::source(&e)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
@@ -575,6 +690,13 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
req.stream = true;
|
req.stream = true;
|
||||||
let body = self.convert_request(req);
|
let body = self.convert_request(req);
|
||||||
|
|
||||||
|
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||||
|
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||||
|
let summary = Self::summarize_messages(&body.messages);
|
||||||
|
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||||
|
anyhow::bail!("messages 预检失败: {} | 摘要: {}", reason, summary);
|
||||||
|
}
|
||||||
|
|
||||||
debug!(model = %body.model, "Anthropic 流式调用");
|
debug!(model = %body.model, "Anthropic 流式调用");
|
||||||
|
|
||||||
let resp = match self
|
let resp = match self
|
||||||
@@ -597,7 +719,15 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
source = ?std::error::Error::source(&e),
|
source = ?std::error::Error::source(&e),
|
||||||
"Anthropic 流式 send 失败"
|
"Anthropic 流式 send 失败"
|
||||||
);
|
);
|
||||||
anyhow::bail!("Anthropic 流式 send 失败: {}", e);
|
anyhow::bail!(
|
||||||
|
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]",
|
||||||
|
e,
|
||||||
|
e.is_timeout(),
|
||||||
|
e.is_connect(),
|
||||||
|
e.is_body(),
|
||||||
|
e.is_request(),
|
||||||
|
std::error::Error::source(&e)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -458,24 +458,44 @@ impl ContextManager {
|
|||||||
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||||
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||||
let mut skipped = 0u32;
|
let mut skipped = 0u32;
|
||||||
let result: Vec<ChatMessage> = messages
|
let mut merged = 0u32;
|
||||||
.into_iter()
|
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||||
.skip_while(|m| {
|
for m in messages {
|
||||||
if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||||
|
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
true
|
continue;
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
})
|
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||||
.collect();
|
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||||
if skipped > 0 {
|
if let Some(last) = fixed.last_mut() {
|
||||||
|
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
|
||||||
|
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
|
||||||
|
if !m.content.is_empty() {
|
||||||
|
if !last.content.is_empty() {
|
||||||
|
last.content.push('\n');
|
||||||
|
}
|
||||||
|
last.content.push_str(&m.content);
|
||||||
|
}
|
||||||
|
if matches!(m.role, MessageRole::Assistant) {
|
||||||
|
if let Some(calls) = m.tool_calls {
|
||||||
|
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixed.push(m);
|
||||||
|
}
|
||||||
|
if skipped > 0 || merged > 0 {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
skipped,
|
skipped,
|
||||||
"序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)"
|
merged,
|
||||||
|
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
result
|
fixed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||||
@@ -952,17 +972,22 @@ mod tests {
|
|||||||
fn over_budget_trims_old() {
|
fn over_budget_trims_old() {
|
||||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||||
let mut mgr = ContextManager::new(cfg(200));
|
let mut mgr = ContextManager::new(cfg(200));
|
||||||
|
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
|
if i % 2 == 0 {
|
||||||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||||||
|
} else {
|
||||||
|
mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||||
assert!(trimmed, "超预算应触发裁剪");
|
assert!(trimmed, "超预算应触发裁剪");
|
||||||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||||||
|
|
||||||
// 保护区:最新一条必保留
|
// 保护区:最新一条必保留(末条 i=19 是 assistant)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
msgs.last().unwrap().content,
|
msgs.last().unwrap().content,
|
||||||
"这是第 19 条较长的消息用于撑爆预算",
|
"第 19 条较长的回复用于撑爆预算",
|
||||||
"保护区最新消息被误裁"
|
"保护区最新消息被误裁"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -213,9 +213,9 @@ pub fn is_non_chat_model(id: &str) -> bool {
|
|||||||
|| id.contains("transcription")
|
|| id.contains("transcription")
|
||||||
// 内容审查
|
// 内容审查
|
||||||
|| id.contains("moderation")
|
|| id.contains("moderation")
|
||||||
// 旧 GPT 搜索变种
|
// 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat
|
||||||
|
// 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除)
|
||||||
|| id.contains("davinci-search")
|
|| id.contains("davinci-search")
|
||||||
|| id.contains("-search")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
|
/// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。
|
||||||
@@ -277,6 +277,17 @@ impl ModelsList {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ──
|
||||||
|
#[test]
|
||||||
|
fn is_non_chat_model_keeps_search_augmented_chat() {
|
||||||
|
// search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔
|
||||||
|
assert!(!is_non_chat_model("gpt-4o-search-preview"));
|
||||||
|
assert!(!is_non_chat_model("qwen-search"));
|
||||||
|
assert!(!is_non_chat_model("glm-4-search-preview"));
|
||||||
|
// 旧 GPT 搜索变种仍剔(davinci-search 覆盖)
|
||||||
|
assert!(is_non_chat_model("text-davinci-search-001"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── build_models_url:尾 / 处理 ──
|
// ── build_models_url:尾 / 处理 ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
df-types = { path = "../df-types" }
|
|
||||||
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 }
|
||||||
tracing = { workspace = true }
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
cmd.output(),
|
cmd.output(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
|
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, request.command))??,
|
||||||
None => cmd.output().await?,
|
None => cmd.output().await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
//! 想法晋升 — 将想法转为项目
|
//! 想法晋升 — PromotionResult(promote_idea IPC 返回类型)。
|
||||||
//!
|
//! 实际晋升路径:commands/idea.rs::promote_idea → df-project::ProjectManager::create_from_idea。
|
||||||
//! 历史的 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套
|
//! 历史空壳 IdeaPromoter/PromotionPolicy 已删(R-PD-14)。
|
||||||
//! 是空壳 TODO(do_promote 从未接入 df-project,误接入会得虚假成功),
|
|
||||||
//! 实际晋升路径由 src-tauri/src/commands/idea.rs::promote_idea 直接复用
|
|
||||||
//! df-project::ProjectManager::create_from_idea 完成。这里仅保留作为
|
|
||||||
//! IPC 返回类型的 PromotionResult。
|
|
||||||
//!
|
|
||||||
//! 死代码已清,详见 docs/todo.md R-PD-14。
|
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ pub struct CreateProjectInput {
|
|||||||
pub struct ProjectManager;
|
pub struct ProjectManager;
|
||||||
|
|
||||||
impl ProjectManager {
|
impl ProjectManager {
|
||||||
/// 创建新项目
|
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
|
||||||
///
|
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
|
||||||
/// TODO: 接入存储层持久化
|
|
||||||
pub fn create(input: CreateProjectInput) -> Project {
|
pub fn create(input: CreateProjectInput) -> Project {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
Project {
|
Project {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ fn clear_migration_failcount(id: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn entry_for(id: &str) -> anyhow::Result<Entry> {
|
fn entry_for(id: &str) -> anyhow::Result<Entry> {
|
||||||
Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e))
|
Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败(provider={}): {}", id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 provider 密钥(优先 keyring;无则 None)
|
/// 读取 provider 密钥(优先 keyring;无则 None)
|
||||||
@@ -112,13 +112,13 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
|||||||
/// 写入密钥到 keyring(覆盖)
|
/// 写入密钥到 keyring(覆盖)
|
||||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||||
let entry = entry_for(id)?;
|
let entry = entry_for(id)?;
|
||||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e))
|
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 keyring 密钥(provider 删除时清理)
|
/// 删除 keyring 密钥(provider 删除时清理)
|
||||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||||
let entry = entry_for(id)?;
|
let entry = entry_for(id)?;
|
||||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e))
|
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||||
|
|||||||
@@ -10,6 +10,5 @@ serde_json = { workspace = true }
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
thiserror = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! 事件总线 — 基于 tokio::sync::broadcast 的发布/订阅
|
//! 事件总线 — 基于 tokio::sync::broadcast 的发布/订阅
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::sync::broadcast::error::SendError;
|
|
||||||
use df_types::events::WorkflowEvent;
|
use df_types::events::WorkflowEvent;
|
||||||
|
|
||||||
/// 事件总线
|
/// 事件总线
|
||||||
@@ -39,11 +38,6 @@ impl EventBus {
|
|||||||
pub fn subscribe(&self) -> EventSubscriber {
|
pub fn subscribe(&self) -> EventSubscriber {
|
||||||
self.sender.subscribe()
|
self.sender.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送人工审批请求
|
|
||||||
pub fn emit_human_approval_request(&self, event: WorkflowEvent) -> Result<usize, SendError<WorkflowEvent>> {
|
|
||||||
self.sender.send(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EventBus {
|
impl Default for EventBus {
|
||||||
|
|||||||
21
docs/todo.md
21
docs/todo.md
@@ -162,7 +162,7 @@
|
|||||||
**🔴 P0 必须修复 (4项)**:
|
**🔴 P0 必须修复 (4项)**:
|
||||||
|
|
||||||
- [x] ✅(workflow w5siwnipj 核验+主代实施·agentic:348/knowledge_inject:130,139,312/idea:292-296 加 tracing::warn 降级不改返回值·audit:344 误报(CR-11 已修三路 match)·commands:290 BUG-11 已修·cargo check EXIT 0 + vue-tsc EXIT 0) **SMELL-P0-1** — **[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。agentic.rs:348(provider 池空走原空池兜底)/knowledge_inject.rs:130,139,312(检索/解析失败走原降级不注入/提炼跳过)/idea.rs:296(tags 坏降级空继续评估)本批 match+warn(空 Vec 零行为变更);audit.rs:344 误报(audit_finalize CR-11 已修三路 match,line 漂移+旧版本);commands.rs:290 BUG-11 已修。**58 处全量排查降 P2 非紧急**(高危已清,余为 Option 取默认/JSON 序列化等低危)。
|
- [x] ✅(workflow w5siwnipj 核验+主代实施·agentic:348/knowledge_inject:130,139,312/idea:292-296 加 tracing::warn 降级不改返回值·audit:344 误报(CR-11 已修三路 match)·commands:290 BUG-11 已修·cargo check EXIT 0 + vue-tsc EXIT 0) **SMELL-P0-1** — **[P0🔴]** `unwrap_or_default` 吞错 **5 高危闭环**。agentic.rs:348(provider 池空走原空池兜底)/knowledge_inject.rs:130,139,312(检索/解析失败走原降级不注入/提炼跳过)/idea.rs:296(tags 坏降级空继续评估)本批 match+warn(空 Vec 零行为变更);audit.rs:344 误报(audit_finalize CR-11 已修三路 match,line 漂移+旧版本);commands.rs:290 BUG-11 已修。**58 处全量排查降 P2 非紧急**(高危已清,余为 Option 取默认/JSON 序列化等低危)。
|
||||||
- [ ] **SMELL-P0-2** — **[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_crud_tools / register_file_tools / register_workflow_tools 等),每个 <200 行可测试。
|
- [x] ✅(workflow w2xkw4ybh 抽 register_data_tools(18 持 db 工具:list_projects/list_tasks/list_ideas/update_project/create_project/bind_directory/create_task/update_task/advance_task/run_workflow/delete_task/create_idea/delete_project/restore_project/purge_project/list_trash/get_project_count/get_task_count)+ 主代抽 register_file_tools(10 文件工具:run_command/read_file/list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/search_files)·build_ai_tool_registry 1091→7 行·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory 断言 len()==28 + tool_names() 集合锁定)·主代核查 cargo check --workspace EXIT 0 + cargo test df-ai 119 passed + devflow 基线 1 passed + vue-tsc EXIT 0) **SMELL-P0-2** — **[P0🔴]** `tool_registry.rs:363 build_ai_tool_registry` **1091 行单函数** — 拆分为按功能分组注册函数(register_data_tools 18 db 工具 / register_file_tools 10 文件工具),build_ai_tool_registry 收敛到 7 行编排 + 基线测试锁定 28 工具数防回归。
|
||||||
- [ ] **SMELL-P0-3** — **[P0🔴]** `AiChat.vue` **4026 行 God 组件** — 拆分: ConversationSidebar(侧栏+搜索) / MessageList(消息列表+流式) / ChatInput(输入框+附件) / ApprovalPanel(审批卡片)。目标单组件 <500 行。
|
- [ ] **SMELL-P0-3** — **[P0🔴]** `AiChat.vue` **4026 行 God 组件** — 拆分: ConversationSidebar(侧栏+搜索) / MessageList(消息列表+流式) / ChatInput(输入框+附件) / ApprovalPanel(审批卡片)。目标单组件 <500 行。
|
||||||
- [x] ✅(workflow wowdnw4ba·tests/shell.rs 5 #[tokio::test] 覆盖成功/非零/超时/env/working_dir + shell.rs:55 过时TODO注释清理·execute逻辑零改动·主代 cargo test -p df-execute 5 passed + cargo check --workspace EXIT 0) **SMELL-P0-4** — **[P0🔴]** `df-execute` crate **零测试**(shell.rs 120行已集成工作流) — 补 shell 命令执行+超时+输出截断基础测试。
|
- [x] ✅(workflow wowdnw4ba·tests/shell.rs 5 #[tokio::test] 覆盖成功/非零/超时/env/working_dir + shell.rs:55 过时TODO注释清理·execute逻辑零改动·主代 cargo test -p df-execute 5 passed + cargo check --workspace EXIT 0) **SMELL-P0-4** — **[P0🔴]** `df-execute` crate **零测试**(shell.rs 120行已集成工作流) — 补 shell 命令执行+超时+输出截断基础测试。
|
||||||
|
|
||||||
@@ -186,6 +186,13 @@
|
|||||||
- [x] ✅(workflow w5siwnipj 核验·line 3 模块注释已有「⚠ B 路线占位...有意保留空壳,勿删」三要素齐全·零外部调用·保留 B 路线预留点) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap **已完成**(line 3 注释),保留非删(B 路线 aichat 决策能力预留,对齐 memory aichat-arch-extensibility)
|
- [x] ✅(workflow w5siwnipj 核验·line 3 模块注释已有「⚠ B 路线占位...有意保留空壳,勿删」三要素齐全·零外部调用·保留 B 路线预留点) **SMELL-P2-4** — coordinator.rs 空壳标注 roadmap **已完成**(line 3 注释),保留非删(B 路线 aichat 决策能力预留,对齐 memory aichat-arch-extensibility)
|
||||||
- [x] ✅(workflow w5siwnipj 核验 7 处+主代补 #6/#7 注释·全部标注完成·cargo check df-ai EXIT 0) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**(实际 7 非声称 12,部分已清)。5 处原有注释(prompt.rs:147/compress.rs:45,98 F-15 阶段预留 + openai_compat.rs:81,98 SW-24 反序列化对齐)+ 本批补 anthropic_compat.rs:61 id/:65 stop_reason 注释(对齐 openai SW-24 风格)。全合理保留无真死代码。**另:cargo warning 死字段 4 处**(PendingApproval.risk_level/diff + AppState.releases/node_executions)属另一类未标 allow,待核对。
|
- [x] ✅(workflow w5siwnipj 核验 7 处+主代补 #6/#7 注释·全部标注完成·cargo check df-ai EXIT 0) **SMELL-P2-5** — #[allow(dead_code)] **7 处全部标注完成**(实际 7 非声称 12,部分已清)。5 处原有注释(prompt.rs:147/compress.rs:45,98 F-15 阶段预留 + openai_compat.rs:81,98 SW-24 反序列化对齐)+ 本批补 anthropic_compat.rs:61 id/:65 stop_reason 注释(对齐 openai SW-24 风格)。全合理保留无真死代码。**另:cargo warning 死字段 4 处**(PendingApproval.risk_level/diff + AppState.releases/node_executions)属另一类未标 allow,待核对。
|
||||||
|
|
||||||
|
**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**:
|
||||||
|
|
||||||
|
- [ ] ARC-260618-01-d [P1/行为变更·待确认产品意图] — **conditions 条件引擎未求值**。`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
|
||||||
|
- [ ] ARC-260618-01-e [P1/行为变更·待确认产品意图] — **adversarial `evaluate_with_llm` 一致性未校验**。`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
|
||||||
|
- [ ] SW-260618-21 [P2/待 AiChat 沉淀] — **formatRelativeZh 重命名 formatRelative**。`src/utils/time.ts` `formatRelativeZh` 名带 Zh 后缀但实际非中文硬编码(已读 i18n.global.locale, sweep 批2 time.ts formatDate 已修 CR-260615-08 漏修),函数名误导。4 调用点含 `AiChat.vue`(并发改动中),为避免并发冲突缓做,待 AiChat God 组件拆分(SMELL-P0-3)沉淀后统一重命名。— `src/utils/time.ts`(formatRelativeZh) + 4 调用点(含 `src/components/AiChat.vue`)
|
||||||
|
- [ ] SW-260618-22 [P2/已知 B-260617-03] — **useAiSend resolveLang DRY**。`useAiSend` 的 `resolveLang` 与其他 composable 语言解析逻辑重复,应抽 `aiShared` 共享。已知登记为 B-260617-03,此处补 sweep 维度记录便于检索。— `src/composables/ai/useAiSend.ts`(resolveLang) → 抽 `src/composables/ai/aiShared.ts`
|
||||||
|
|
||||||
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
|
### 🟡 AI Chat 交互体验改进(2026-06-14 方案 → 待办化)
|
||||||
|
|
||||||
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
|
> 来源:[aichat交互体验改进方案-2026-06-14.md](./02-架构设计/aichat交互体验改进方案-2026-06-14.md)。7 大类 20 个改进项,按原文优先级分 4 批。**去重**:§1.3 复制 ⊂ F-260615-13/14(已写)、§3.4 新建中断 ⊂ B-260615-10(已做软复位,本文档加 confirm 增强)、§6.2 分离窗口同步 ⊂ CR-24/25(已写)。**状态: 待讨论→看板跟踪**。
|
||||||
@@ -329,3 +336,15 @@
|
|||||||
- [x] ✅(主代串行·规避 AiChat God 文件·新建 src/utils/markdown.ts stripMd 剥离 MD 语法(粗体/斜体/标题/代码/列表/引用/链接/图片)+ Knowledge.vue:71/Projects.vue:148/Ideas.vue:47 三列表摘要接入·修自引入注释含 */ 致 JSDoc 提前闭合 TS1011/1127·改安全措辞·vue-tsc EXIT 0) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。三处列表摘要 stripMd 过滤。**AiChat 标题 strip 子项**:规避 God 高频文件·留待并发期后(待核 title.rs 是否产 MD)。— `src/views/{Knowledge,Projects,Ideas}.vue` + `src/utils/markdown.ts::stripMd`
|
- [x] ✅(主代串行·规避 AiChat God 文件·新建 src/utils/markdown.ts stripMd 剥离 MD 语法(粗体/斜体/标题/代码/列表/引用/链接/图片)+ Knowledge.vue:71/Projects.vue:148/Ideas.vue:47 三列表摘要接入·修自引入注释含 */ 致 JSDoc 提前闭合 TS1011/1127·改安全措辞·vue-tsc EXIT 0) **UX-260618-13** [P2] — **列表摘要 Markdown 语法字符泄露**。三处列表摘要 stripMd 过滤。**AiChat 标题 strip 子项**:规避 God 高频文件·留待并发期后(待核 title.rs 是否产 MD)。— `src/views/{Knowledge,Projects,Ideas}.vue` + `src/utils/markdown.ts::stripMd`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 🔧 2026-06-18 用户实测·aichat bug 反馈(review 会话 2 agent 并行审查·独立 grep/read 核验·session-role-diagnose-only·待实施)
|
||||||
|
|
||||||
|
> 用户实测报 2 bug。review 会话派 2 agent 并行核验源码,根因 + 修法定位完成,待主代实施。
|
||||||
|
|
||||||
|
- [ ] **UX-260618-14 [P1]** — **advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。①审批卡 `ToolCard.vue:800` `PROJECT_ID_TOOL_ARG` 未登记 advance_task → id 裸显 UUID;②审批 reason `audit.rs:163` tool_display_hint 短路致 `tool_registry.rs:1546` 模板死代码 + `:172` 任务 id 误查 projects 表;③结果 `ToolCard.vue:477` 模板取 r.id(UUID)非 r.title(TaskRecord 有 title `models.rs:57`),"空白"=UUID 不可读非字段缺失。**修法 B(最快)**:ToolCard:477 取 r.title fallback r.id + i18n 改 `任务「{title}」已推进至 {status}`;**A1** 登记 TASK_ID_TOOL_ARG(advance_task/run_workflow)id→title 回显;**C** 删 `tool_registry.rs:1572` tool_display_hint 让 :1546 模板生效 + audit.rs 加 resolve_task_label。附带:`:1546/:1547` 模板均死代码 + audit.rs:172 任务 id 误查隐患。— `src/components/ToolCard.vue`(:474-478,800-825) + `src-tauri/src/commands/ai/{tool_registry.rs:1546/1572,audit.rs:163/172}` + `src/i18n/{zh-CN,en}/aiTool.ts`
|
||||||
|
|
||||||
|
- [ ] **UX-260618-15 [P1]** — **流式失败重试(1/4 2/4 3/4)每轮独立气泡**。后端每轮重试 emit AiError(`stream_recv.rs:200/258/298/358/398` 5 处 InitFailed 必 emit),前端 `useAiEvents.ts:326` AiError case 每次 push 新气泡无去重;`AiStreamRetry`(:177)本意更新末条合并但被新 AiError 破坏 → N+1 独立气泡。`agentic.rs:262` 注释误以为 stream_llm 只 emit 一次(实多次)。**用户诉求升级**:不只是修 bug,要**聚合显示设计**(重试 1→4 轮过程聚合单气泡 + 最终结果,勿一排错误吓人)。详见 review 会话重试聚合设计方案(3 角度论证:可行性/前后端一致性/架构整合)。**修法 A(推荐)**:`stream_recv.rs` 5 处 InitFailed 移除 AiError emit(改纯返 retryable,emit 权交调用方)+ `agentic.rs` 重试 loop 只 emit AiStreamRetry、耗尽/Fatal 才 emit 最终 AiError。影响仅流前失败重试路径(Fatal/MidStream 保文/正常错误/压缩失败不受影响)。— `src-tauri/src/commands/ai/{stream_recv.rs:200/258/298/358/398,agentic.rs:207-300}` + `src/composables/ai/useAiEvents.ts:177/326`
|
||||||
|
|
||||||
|
- [ ] **UX-260618-16 [P1🔴]** — **`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**(CR-260618-24 批2 审查发现·Agent C 铁证)。`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 下 formatDate 永走 zh-CN 分支,i18n 化失效(对齐 CR-260615-08 未完成回归)。铁证:`App.vue:227`/`GeneralPanel.vue:259` 均用 `i18n.global.locale.value`,唯 time.ts:44 漏。**修法**:补 `.value` → `(i18n as any).global.locale.value === 'en'`(locale 取值 'en' 非 'en-US',App.vue:227 印证)。— `src/utils/time.ts:44`
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
10
docs/待决策.md
10
docs/待决策.md
@@ -110,12 +110,12 @@
|
|||||||
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。
|
- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。
|
||||||
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍)
|
- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍)
|
||||||
- **选项**:
|
- **选项**:
|
||||||
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中)
|
- a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) — **✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0**
|
||||||
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口)
|
- b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓)
|
||||||
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险)
|
- c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期
|
||||||
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做,b 待 AiChat 改动沉淀后)
|
- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后)
|
||||||
- **关联**:todo SMELL-P0-2/P0-3/P1-9
|
- **关联**:todo SMELL-P0-2/P0-3/P1-9
|
||||||
- **状态**:🟡 待排期决策
|
- **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口)
|
||||||
|
|
||||||
#### SMELL-P1-6 String→newtype 强类型(需设计)
|
#### SMELL-P1-6 String→newtype 强类型(需设计)
|
||||||
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。
|
- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。
|
||||||
|
|||||||
80
docs/待审查.md
80
docs/待审查.md
@@ -547,6 +547,86 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### CR-260618-24 God拆分a(tool_registry file+data抽出+基线测试) + sweep批1-4死依赖/死代码/死导出/注释/i18n/model_fetch(workflow w2xkw4ybh data抽+主代file抽+主代sweep实施·工作区未提交·commit-policy-deferred) — ✅ 已审(ISSUES·🔴1🟡1·review 会话 3 agent 并行核验)
|
||||||
|
|
||||||
|
- **范围**:
|
||||||
|
- **God 拆分 a(SMELL-P0-2)**:`tool_registry.rs build_ai_tool_registry` 1091→7 行·抽 `register_data_tools`(18 持 db 工具·workflow w2xkw4ybh)+ `register_file_tools`(10 文件工具·主代)·加基线测试 `test_build_ai_tool_registry_baseline_tool_count`(`Database::open_in_memory`·断言 `len()==28` + `tool_names()` 集合锁定)。
|
||||||
|
- **批1 死依赖/死代码(Rust)**:`df-execute/Cargo.toml` 删 df-types/async-trait/tracing(全仓零引用) · `df-workflow/Cargo.toml` 删 thiserror(零引用) · `df-workflow/eventbus.rs` 删 `emit_human_approval_request`(零调用·实际走 `send()`)+ SendError import · `df-ai/ai_tools.rs` 删 `ToolExecutionResult`(零消费者)。
|
||||||
|
- **批2 前端**:`useMarkdown.ts` 删 `clearHistoryCache`(零消费) · `useConfirm.ts` 删 `ConfirmFn`/`AnswerFn`(零 import) · `useAiPanel.ts` `restoreUiState` 去 `export`(零外部调用) · `markdown.ts` stripMd 围栏正则 `[a-zA-Z]*`→`[^\n]*`(吃任意 infostring 如 rust,norun) · `time.ts` `formatDate` 读 `i18n.global.locale`(原硬编码 zh-CN·CR-260615-08 漏修)。
|
||||||
|
- **批3 注释**:`df-project/manager.rs` 删误导 TODO(分层正确·持久化在 storage 层) · `df-ideas/promotion.rs` 精简模块注释(历史空壳已删)。
|
||||||
|
- **批4 med**:`df-ai/model_fetch.rs` `is_non_chat_model` 删 `|| id.contains("-search")`(误剔合法 search-augmented chat 模型 gpt-4o-search-preview·davinci-search 仍覆盖旧变种)+ 补单测 `is_non_chat_model_keeps_search_augmented_chat`。
|
||||||
|
- **验证**:`cargo check --workspace` EXIT 0(0 warning) / `cargo test df-ai` 119 passed / `cargo test -p devflow test_build_ai_tool_registry_baseline_tool_count` 1 passed / `vue-tsc` EXIT 0。
|
||||||
|
- **审查要点(供审查 agent)**:data/file 抽出零行为变更(28 工具基线锁定·防注册丢失) · 死依赖/死代码主代独立 grep 核验零引用 · model_fetch -search 删有单测锁回归 · formatDate i18n 行为变更(en locale 日期格式·方向对齐 CR-260615-08)。
|
||||||
|
- **关联**:SMELL-P0-2 销账 / ARC-260618-01 a ✅ 已实施 / ARC-d(conditions)/e(adversarial)/SW-21(formatRelativeZh)/22(useAiSend DRY) 进 todo。
|
||||||
|
- **复审结论(2026-06-18·review 会话 3 agent 并行独立核验,不信主代声明)**: ⚠️ **ISSUES** — 🔴1 🟡1 ⚪1
|
||||||
|
- ✅ **God 拆分 PASS**(Agent A):build 调 register_data_tools(:367)+ register_file_tools(:368),build 体零残留内联;**重名注册闭环**(巡检 #11 信号消除:28 name 静态互异 + 基线双断言 len==28 + sorted 集合防护 HashMap 覆盖);抽样 run_command/list_projects 逐字一致(get_*_count 仅位置移动非行为变更)
|
||||||
|
- ✅ **批1 死代码全 PASS**(Agent B):df-execute 删 df-types/async-trait/tracing + df-workflow 删 thiserror + eventbus 删 emit_human_approval_request/SendError + ai_tools 删 ToolExecutionResult,**4 项全仓 grep 零引用**(人类审批走 `bus.send()` 非删的 emit),无误删活引用
|
||||||
|
- ✅ **批4 model_fetch PASS**(Agent C):删 `|| id.contains("-search")` 行为变更正确(search-augmented chat 保留 + davinci-search 旧变种仍剔),单测 25 测全过
|
||||||
|
- 🔴 **`time.ts:44` formatDate 漏 `.value`**(Agent C 铁证):`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 永不生效,formatDate i18n 化失效(对齐 CR-260615-08 未完成回归)。`App.vue:227`/`GeneralPanel:259` 均用 `.value` 印证。修法:补 `.value`。
|
||||||
|
- 🟡 **`markdown.ts:19` stripMd 围栏正则边缘回归**:`[a-zA-Z]*`→`[^\n]*` 后,内联裸围栏无换行(`See \`\`\`rust below`)吃到行尾。场景边缘(列表摘要极少内联裸围栏),建议注释标注边界
|
||||||
|
- ⚪ **`anthropic_compat.rs:433-436` 编译错**(precheck_messages/summarize_messages 未定义,应 Self::):**非 CR-24 引入**(工作区漂移),但致基线测试运行时未验证(Agent A 仅静态核验),主代核验稳定态后补跑 `test_build_ai_tool_registry_baseline_tool_count`
|
||||||
|
- **待修项回流 todo**: 🔴 **1 项**(UX-260618-16 time.ts formatDate 补 .value)
|
||||||
|
|
||||||
|
**第二位审查 agent 独立核验印证(2026-06-18·独立 grep/read·防上下文污染)**:
|
||||||
|
- ✅ 与 review 会话 3 agent 结论一致:🔴1(time.ts .value) / 🟡1(markdown.ts 边缘) / ⚪1(anthropic_compat 漂移)
|
||||||
|
- **🔴 time.ts:44 漏 .value 二次独立坐实**:`i18n/index.ts:17 legacy:false`(composition 模式)→ `i18n.global.locale` 是 `WritableComputedRef` 非 string。`App.vue:227` + `GeneralPanel.vue:259` 均用 `i18n.global.locale.value = ...` 印证 ref 语义;`(i18n as any).global.locale === 'en'` 比较 ref 对象与字符串 **恒 false** → en locale 永不生效。**本审查 agent 首轮未捕捉此对抗点,经 review 会话坐实后独立复核确认**——印证对抗思维不可省(`.value` 是 vue-i18n legacy/composition 模式高频易错点)。
|
||||||
|
- **A. God 拆分独立佐证**(超出对方会话覆盖):grep df-ai 全 crate `#[(tokio::)?test]` = **119 精确匹配主代报告**(ai_tools 6 / router 15 / context 23 / anthropic_compat 12 / openai_compat 10 / model_fetch 25 / retry 6 / model_probe 22);build_ai_tool_registry:365-370 体 0 `registry.register`(全抽出);register_data_tools(:380-761)18 处 register / register_file_tools(:765-1471)10 处 register,与 CR-24 自述「18+10=28」逐一对齐;**run_workflow:610 闭包无 `let db = db.clone()` 前缀**(对比其余 17 data 工具全有),CR-52 防御 Err 设计保留;RiskLevel 28 处计数与 28 工具一一对应。
|
||||||
|
- **B. 批1 死代码独立佐证**:df-execute/Cargo.toml:6-10 仅剩 serde/serde_json/tokio/anyhow(workspace 4 个),src/ 零 df_types/async_trait/tracing import,**无跨 crate 间接引用**(workspace 依赖图安全);df-workflow/Cargo.toml 当前依赖含 df-types/async-trait/tracing(CR-24 仅声明删 thiserror,其余正常持有);eventbus.rs:1-56 仅 send/subscribe/with_capacity/Default/Clone;ai_tools.rs ToolExecutionResult 全仓零引用。
|
||||||
|
- **C. 批2 前端独立佐证**:clearHistoryCache/ConfirmFn/AnswerFn 全 src 零命中(仅待审查.md 自述);useAiPanel.ts:49 `function restoreUiState()` 无 export,:52 模块内自调;markdown.ts:19 `/```[^\n]*\n?/g`(非 `[a-zA-Z]*`)印证。
|
||||||
|
- **D. 批3 注释独立佐证**:manager.rs grep TODO 零命中,create() doc :46-47「构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成」;promotion.rs:1-3 模块注释 3 行精简。
|
||||||
|
- **E. 批4 med 独立佐证**:model_fetch.rs:196-219 is_non_chat_model 列表(图片/语音/realtime/transcribe/moderation/:218 `davinci-search`),**无 `-search` 残留**;新测试 :281-289 4 断言(3 search-augmented 保留 + 1 davinci-search 仍剔);另有 :394-396 non_chat_search_variants_filtered 印证。
|
||||||
|
- **🟡 markdown.ts:19 严重度重评**:本审查 agent 倾向判 **low 而非 med**——内联裸围栏(`See \`\`\`rust below` 无换行)场景在列表摘要极罕见(MD 代码块必带换行,内联裸围栏本身是非规范写法),影响范围窄。对方会话判 med 偏保守,差异在严重度估计非事实分歧。建议修法:加 `[^\n]*` 边界注释标注「内联裸围栏场景」即可,无需改正则。
|
||||||
|
- **⚪ anthropic_compat.rs:433-436 编译错印证**:确认非 CR-24 引入(工作区漂移),需主代核验稳定态后补跑基线测试(对方已记录)。
|
||||||
|
|
||||||
|
**主代修复闭环(2026-06-18)**: 🔴 `time.ts:44` 已补 `.value`(`(i18n as any).global.locale.value`·对齐 App.vue:227)·`vue-tsc` EXIT 0 印证 en locale 现正确生效。CR-24 🔴 消·verdict 转 PASS(🟡 markdown.ts:19 边界注释 low 可选·⚪ anthropic_compat 漂移非本 CR 待独立核验)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CR-260618-25 wo7d6zdqm findings 推进(secret keyring provider id + shell 超时命令名 + run_command timeout clamp 600s) + wdvtbjozg ux(异步按钮禁用态防双击 + 空态/错误态·7 vue + i18n)(workflow 多代理 + 主代实施·工作区未提交·commit-policy-deferred) — ✅ 已审(PASS·主代自测+workflow)
|
||||||
|
|
||||||
|
- **范围**:
|
||||||
|
- **wo7d6zdqm 错误消息/安全(主代实施)**:`df-storage/secret.rs` keyring 3 处错误带 provider id(entry_for/set/delete) · `df-execute/shell.rs:102` 超时消息带命令名 · `tool_registry.rs run_command` timeout_secs clamp 600s(防 LLM 传超大冻结) · `utils/time.ts formatDate` 补 `.value`(CR-24 🔴 修复)。
|
||||||
|
- **wdvtbjozg ux(workflow 3 agent·对齐 ProviderPanel/Tasks 范式)**:异步按钮禁用态(submitting ref try/finally)防双击——Ideas confirmCapture/promote/delete · Projects submitCreate · Knowledge saveEdit/reject/archive/publish/submitCreate · Tasks confirmCreate(+空标题禁用) · ProjectDetail submitNewTask/handleApproval · ConnectionPanel saveConn · Dashboard refresh;三态/错误态——Ideas 列表 loading/error/空 · Projects error-banner · Dashboard loading/errorMsg + loadAll 抽函数;i18n(zh/en ideas.ts listEmpty/creating/deleting/promoting · dashboard.ts err.loadFailed)。
|
||||||
|
- **验证**:`cargo check -p devflow -p df-storage -p df-execute` EXIT 0 / `vue-tsc` EXIT 0。
|
||||||
|
- **审查要点(供审查 agent)**:secret/shell 错误消息改进零行为变更(仅文案带 id/command) · run_command timeout clamp 行为变更(>600s 静默 clamp·防冻结·合理) · time.ts .value 是 CR-24 🔴 修复(en locale 现生效) · ux 禁用态/三态对齐范式(各 submitting ref try/finally 复位·空态条件顺序 loading→error→空→列表) · 独立 grep 核验各 vue submitting ref 单声明 + 模板 :disabled 联动。
|
||||||
|
- **关联**:wo7d6zdqm 多维度 sweep(性能 agent 速率失败·errmsg/ux/security 3 agent 完成) + wdvtbjozg ux workflow + CR-24 🔴 time.ts 修。
|
||||||
|
- **待修项回流 todo**: 无
|
||||||
|
|
||||||
|
**复审结论(2026-06-19·独立 grep 核验源码当前形态)**: ✅ PASS · 🔴0 🟡0 ⚪2(low·可选)。verdict 转 PASS。主代自测(cargo check EXIT 0 / vue-tsc EXIT 0)与独立源码核验一致,无 high/med。
|
||||||
|
|
||||||
|
**逐项核验表(file:line 佐证 + 判定)**:
|
||||||
|
|
||||||
|
| 项 | 核验点 | 佐证 | 判定 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A-secret | 3 处 keyring 错误带 provider id | `secret.rs:92` entry_for `(provider={})`+id · `:115` set `(provider={})`+id · `:121` delete `(provider={})`+id | ✅ |
|
||||||
|
| A-shell | 超时 map_err 带命令名 + secs | `shell.rs:102` `命令执行超时({}s): {}` 含 `secs`+`request.command`(ShellRequest 字段在作用域内) | ✅ |
|
||||||
|
| A-clamp | timeout_secs `.min(MAX)` + 常量 600 | `tool_registry.rs:26` `MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600` · `:792` `.unwrap_or(DEFAULT=60).min(MAX)` clamp · `:24` DEFAULT=60(默认 60<600·clamp 不影响正常超时·仅封顶 LLM 传超大冻结) | ✅ |
|
||||||
|
| A-time | formatDate `.value`(CR-24 🔴) | `time.ts:44` `(i18n as any).global.locale.value === 'en'` · 对齐 `App.vue:227` `i18n.global.locale.value = savedLang`(en locale 现正确生效) | ✅ |
|
||||||
|
| B-Ideas | 三态条件顺序 + store 字段 | `Ideas.vue:37-39` `v-if store.loading`→`v-else-if store.error`→`v-else-if filteredIdeas.length===0`→`v-else 列表` · 顺序正确 · `:236` useProjectStore · `project.ts:90-91,93` store.loading/error/clearError 均存在(getter 直读 state) | ✅ |
|
||||||
|
| B-Projects | error-banner + clearError + submitting | `Projects.vue:13-16` `v-if store.error` + dismiss `store.clearError()` · `:51` `:disabled="submitting \|\| !newName.trim()"` | ✅ |
|
||||||
|
| B-Dashboard | loading ref + errorMsg + loadAll + refresh :disabled | `Dashboard.vue:124-125` loading/errorMsg ref · `:194-206` loadAll() 抽函数(try/finally+guard `if(loading) return`) · `:10` refresh `:disabled="loading"` · `:21-24` error-banner | ✅ |
|
||||||
|
| C-Ideas | creating/deleting/promoting 单声明 + try/finally + :disabled | `Ideas.vue:253-255` 三 ref 各一次声明 · `:402/416/428` 入口 guard(`\|\| xxx.value`) · `:404-412/419-424/430-439` try/finally 复位 · `:176/181/207` :disabled 联动 | ✅ |
|
||||||
|
| C-Projects | submitting 单声明 + try/finally + :disabled + guard | `Projects.vue:225` 单声明 · `:278` guard `if(submitting) return`(在 `:279` submitting=true 前) · `:279-290` try/finally · `:51` :disabled | ✅ |
|
||||||
|
| C-Knowledge | submitting 单声明 + 5 函数 try/finally + :disabled | `Knowledge.vue:353` 单声明 · saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 均 try/finally 复位 · `:99/100/102/106/233` :disabled 联动 | ✅ |
|
||||||
|
| C-Tasks | submitting 单声明 + 空标题 guard + try/finally | `Tasks.vue:137` 单声明 · `:222` guard `!newTaskTitle.trim()` 在 `:223` submitting=true 前 · `:223-236` try/finally · `:103` :disabled 联动 · 三态 loading/error/empty `:43-45` | ✅ |
|
||||||
|
| C-ProjectDetail | submitting 单声明 + submitNewTask/handleApproval try/finally + :disabled | `ProjectDetail.vue:324` 单声明 · `:327` guard trim · `:328-343` submitNewTask try/finally · `:418-424` handleApproval try/finally · `:36/211` :disabled(审批选项按钮防双击换选项) | ✅ |
|
||||||
|
| C-ConnectionPanel | submitting 单声明 + saveConn + :disabled | `ConnectionPanel.vue:110` 单声明 · `:157-177` try/finally · `:63` :disabled | ✅(low 见下) |
|
||||||
|
| D-i18n-zh | ideas listEmpty/creating/deleting/promoting + dashboard err.loadFailed | `zh-CN/ideas.ts:20-23` · `zh-CN/dashboard.ts:37` `loadFailed: '加载数据失败,请稍后重试'` | ✅ |
|
||||||
|
| D-i18n-en | 对称 | `en/ideas.ts:20-23` · `en/dashboard.ts:37` `loadFailed: 'Failed to load data, please retry later'` | ✅ |
|
||||||
|
|
||||||
|
**对抗核验印证**:
|
||||||
|
- **submitting ref 单声明?** ✅ 7 vue 全部 setup 内单声明(无重复 declare):Ideas `:253-255` 三 ref·Projects `:225`·Dashboard `:124`·Knowledge `:353`·Tasks `:137`·ProjectDetail `:324`·ConnectionPanel `:110`。无重复声明。
|
||||||
|
- **try/finally 复位?** ✅ 所有异步函数均 try/finally 复位(异常路径不卡死)。无漏 finally。
|
||||||
|
- **store 字段存在?** ✅ project store(`project.ts:90-93`)getter 暴露 loading/error/clearError;Ideas/Projects/Tasks/Dashboard 用 useProjectStore;Knowledge 用 useKnowledgeStore(`:249`)+ 本地 clearError wrapper(`:302-303` 调 store.clearError)。字段均存在、store 用对。
|
||||||
|
- **行为变更标注?** ✅ 仅 run_command clamp 为静默行为变更(>600s 截断·防 LLM 冻结·合理·注释 `:791` 标注),secret/shell/time.ts 均零行为变更(纯文案/纯 bug 修)。time.ts `.value` 是 CR-24 🔴 必要修复(en locale 原本恒中文·回归)。
|
||||||
|
- **error-banner 重复?** ✅ Ideas/Projects/Dashboard/Knowledge 各自 error-banner(消费各自 store.error / 本地 errorMsg),非同一 store·非冲突·对齐 Knowledge 范式。
|
||||||
|
|
||||||
|
**⚪ low(可选·非必修)**:
|
||||||
|
1. `Knowledge.vue submitCreate`(:438)/ `ProjectDetail.vue handleApproval`(:417) 缺显式 `if(submitting) return` 入口 guard(其余 5 处有)。实际防并发由模板 `:disabled="submitting"` 兜住(按钮点不动·无重入),IPC 路径单调用,风险极低。可选补 guard 对齐范式一致性。
|
||||||
|
2. `ConnectionPanel.vue saveConn`(:152) 是同步函数(无 await),submitting try/finally 同步置位即复位,此处 submitting 实际无防双击语义(纯占位/视觉)。无功能影响,可选移除或改 async。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 已审归档
|
## 已审归档
|
||||||
|
|
||||||
> 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)
|
> 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"dev:restart": "npm run dev:stop && npm run dev",
|
"dev:restart": "npm run dev:stop && npm run dev",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"build:dev": "cargo tauri build --debug"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arco-design/web-vue": "^2.58.0",
|
"@arco-design/web-vue": "^2.58.0",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const MAX_LIST_RESULTS: usize = 50;
|
|||||||
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
|
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
|
||||||
/// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。
|
/// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。
|
||||||
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
|
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
|
||||||
|
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
|
||||||
|
const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||||
|
|
||||||
/// 生成行级 unified diff(无外部依赖,基于 LCS)。
|
/// 生成行级 unified diff(无外部依赖,基于 LCS)。
|
||||||
/// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。
|
/// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。
|
||||||
@@ -358,11 +360,26 @@ async fn bind_dir_to_project(
|
|||||||
|
|
||||||
/// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨)
|
/// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨)
|
||||||
///
|
///
|
||||||
/// CRUD 工具闭包捕获 `db` Arc 重建 Repo;文件系统工具复用 resolve_workspace_path /
|
/// SMELL-P0-2 拆分:原 1091 行单函数拆为两层注册。
|
||||||
/// list_dir_recursive。新增工具只改这里一处,定义与实现同源,编译期保证一致。
|
/// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。
|
||||||
|
/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。
|
||||||
|
/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。
|
||||||
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||||
let mut registry = AiToolRegistry::new();
|
let mut registry = AiToolRegistry::new();
|
||||||
|
register_data_tools(&mut registry, db);
|
||||||
|
register_file_tools(&mut registry);
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
|
||||||
|
///
|
||||||
|
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
|
||||||
|
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
|
||||||
|
/// ai_approve → run_workflow_inner 另走完整 State 路径。
|
||||||
|
///
|
||||||
|
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
|
||||||
|
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
|
||||||
|
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
// ── 只读 (Low) ──
|
// ── 只读 (Low) ──
|
||||||
registry.register(
|
registry.register(
|
||||||
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
|
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
|
||||||
@@ -713,6 +730,41 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
|||||||
})
|
})
|
||||||
})},
|
})},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 探总量工具 (Low risk, F-260616-08) ──
|
||||||
|
registry.register(
|
||||||
|
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
|
||||||
|
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||||
|
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||||
|
let db = db.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||||
|
let items = repo.list_active().await?;
|
||||||
|
Ok(serde_json::json!({ "total": items.len() }))
|
||||||
|
})
|
||||||
|
})},
|
||||||
|
);
|
||||||
|
registry.register(
|
||||||
|
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
|
||||||
|
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
||||||
|
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||||
|
let db = db.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||||
|
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||||
|
repo.query("project_id", pid).await?
|
||||||
|
} else {
|
||||||
|
repo.list_all().await?
|
||||||
|
};
|
||||||
|
Ok(serde_json::json!({ "total": tasks.len() }))
|
||||||
|
})
|
||||||
|
})},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
|
||||||
|
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
|
||||||
|
fn register_file_tools(registry: &mut AiToolRegistry) {
|
||||||
registry.register(
|
registry.register(
|
||||||
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
|
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
|
||||||
df_ai::ai_tools::object_schema(vec![
|
df_ai::ai_tools::object_schema(vec![
|
||||||
@@ -736,7 +788,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
|||||||
None => workspace_root().to_string_lossy().to_string(),
|
None => workspace_root().to_string_lossy().to_string(),
|
||||||
};
|
};
|
||||||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
||||||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS);
|
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||||||
|
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||||||
|
|
||||||
let request = ShellRequest {
|
let request = ShellRequest {
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
@@ -1418,38 +1471,6 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
|||||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 探总量工具 (Low risk, F-260616-08) ──
|
|
||||||
registry.register(
|
|
||||||
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
|
|
||||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
|
||||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
|
||||||
let db = db.clone();
|
|
||||||
Box::pin(async move {
|
|
||||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
|
||||||
let items = repo.list_active().await?;
|
|
||||||
Ok(serde_json::json!({ "total": items.len() }))
|
|
||||||
})
|
|
||||||
})},
|
|
||||||
);
|
|
||||||
registry.register(
|
|
||||||
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
|
|
||||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
|
||||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
|
||||||
let db = db.clone();
|
|
||||||
Box::pin(async move {
|
|
||||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
|
||||||
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
|
||||||
repo.query("project_id", pid).await?
|
|
||||||
} else {
|
|
||||||
repo.list_all().await?
|
|
||||||
};
|
|
||||||
Ok(serde_json::json!({ "total": tasks.len() }))
|
|
||||||
})
|
|
||||||
})},
|
|
||||||
);
|
|
||||||
|
|
||||||
registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||||
@@ -1625,6 +1646,59 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||||||
|
//
|
||||||
|
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
|
||||||
|
// build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。
|
||||||
|
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// build_ai_tool_registry 应注册恰好 28 个工具(18 data + 10 file),且工具名集合稳定。
|
||||||
|
///
|
||||||
|
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||||
|
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_build_ai_tool_registry_baseline_tool_count() {
|
||||||
|
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||||
|
let db = Arc::new(db);
|
||||||
|
let registry = build_ai_tool_registry(&db);
|
||||||
|
|
||||||
|
// 总量基线:28(18 data + 10 file)。拆分前后必须一致。
|
||||||
|
assert_eq!(
|
||||||
|
registry.len(),
|
||||||
|
28,
|
||||||
|
"工具总数应为 28(18 data + 10 file),实际 {}", registry.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||||
|
// data 层 18 个(持 db):CRUD/状态机/工作流
|
||||||
|
// file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜
|
||||||
|
let mut expected: Vec<&str> = vec![
|
||||||
|
// ── data 层 (18) ──
|
||||||
|
"list_projects", "list_tasks", "list_ideas",
|
||||||
|
"update_project", "create_project", "bind_directory",
|
||||||
|
"create_task", "update_task", "advance_task",
|
||||||
|
"run_workflow", "delete_task", "create_idea",
|
||||||
|
"delete_project", "restore_project", "purge_project",
|
||||||
|
"list_trash", "get_project_count", "get_task_count",
|
||||||
|
// ── file 层 (10) ──
|
||||||
|
"run_command", "read_file", "list_directory",
|
||||||
|
"write_file", "patch_file", "file_info",
|
||||||
|
"append_file", "delete_file", "rename_file", "search_files",
|
||||||
|
];
|
||||||
|
expected.sort_unstable();
|
||||||
|
|
||||||
|
let mut actual: Vec<String> = registry.tool_names();
|
||||||
|
actual.sort();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actual,
|
||||||
|
expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||||
|
"工具名集合与基线不一致(可能漏注册或 rename)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
|
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
|
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
|
<button class="btn btn-primary" :disabled="submitting" @click="saveConn">{{ $t('common.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -106,6 +106,9 @@ const connForm = reactive({
|
|||||||
user: '',
|
user: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
const CONN_STORAGE_KEY = 'df-connections'
|
const CONN_STORAGE_KEY = 'df-connections'
|
||||||
|
|
||||||
function loadConnections() {
|
function loadConnections() {
|
||||||
@@ -151,6 +154,8 @@ function saveConn() {
|
|||||||
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const record: ConnRecord = {
|
const record: ConnRecord = {
|
||||||
id: connForm.editId || Date.now().toString(36),
|
id: connForm.editId || Date.now().toString(36),
|
||||||
name: connForm.name,
|
name: connForm.name,
|
||||||
@@ -167,6 +172,9 @@ function saveConn() {
|
|||||||
}
|
}
|
||||||
persistConnections()
|
persistConnections()
|
||||||
connForm.visible = false
|
connForm.visible = false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeConn(id: string) {
|
async function removeConn(id: string) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function applyUiState(s: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||||
export function restoreUiState() {
|
function restoreUiState() {
|
||||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
||||||
}
|
}
|
||||||
restoreUiState()
|
restoreUiState()
|
||||||
|
|||||||
@@ -22,13 +22,6 @@ export interface ConfirmState {
|
|||||||
resolve: null | ((v: boolean) => void)
|
resolve: null | ((v: boolean) => void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。
|
|
||||||
* dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
|
|
||||||
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
|
|
||||||
|
|
||||||
/** 关闭弹层并回传结果 */
|
|
||||||
export type AnswerFn = (ok: boolean) => void
|
|
||||||
|
|
||||||
export function useConfirm() {
|
export function useConfirm() {
|
||||||
const confirmState = ref<ConfirmState>({
|
const confirmState = ref<ConfirmState>({
|
||||||
visible: false,
|
visible: false,
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ export function useMarkdown() {
|
|||||||
escapeFallback,
|
escapeFallback,
|
||||||
getMarked: () => _marked,
|
getMarked: () => _marked,
|
||||||
getPurify: () => _purify,
|
getPurify: () => _purify,
|
||||||
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
|
|
||||||
clearHistoryCache: () => _mdCache.clear(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export default {
|
|||||||
noIdeas: 'No ideas yet, click above to capture',
|
noIdeas: 'No ideas yet, click above to capture',
|
||||||
noData: 'No data',
|
noData: 'No data',
|
||||||
},
|
},
|
||||||
|
// Error fallback (refresh/onMounted load failures surfaced to user)
|
||||||
|
err: {
|
||||||
|
loadFailed: 'Failed to load data, please retry later',
|
||||||
|
},
|
||||||
stage: {
|
stage: {
|
||||||
planning: 'Planning',
|
planning: 'Planning',
|
||||||
coding: 'Coding',
|
coding: 'Coding',
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export default {
|
|||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
emptyState: 'Select an idea to view details',
|
emptyState: 'Select an idea to view details',
|
||||||
|
// List empty / async button placeholders
|
||||||
|
listEmpty: '📭 No ideas yet. Click "Capture Idea" at the top right to start.',
|
||||||
|
creating: 'Creating…',
|
||||||
|
deleting: 'Deleting…',
|
||||||
|
promoting: 'Promoting…',
|
||||||
|
|
||||||
// Adversarial evaluation
|
// Adversarial evaluation
|
||||||
adversarialTitle: '⚖️ Adversarial Evaluation',
|
adversarialTitle: '⚖️ Adversarial Evaluation',
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export default {
|
|||||||
noIdeas: '暂无灵感,点击上方按钮捕捉',
|
noIdeas: '暂无灵感,点击上方按钮捕捉',
|
||||||
noData: '暂无数据',
|
noData: '暂无数据',
|
||||||
},
|
},
|
||||||
|
// 错误回退(refresh/onMounted 加载失败对用户可见)
|
||||||
|
err: {
|
||||||
|
loadFailed: '加载数据失败,请稍后重试',
|
||||||
|
},
|
||||||
stage: {
|
stage: {
|
||||||
planning: '规划中',
|
planning: '规划中',
|
||||||
coding: '编码中',
|
coding: '编码中',
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export default {
|
|||||||
|
|
||||||
// 空状态
|
// 空状态
|
||||||
emptyState: '选择一个灵感查看详情',
|
emptyState: '选择一个灵感查看详情',
|
||||||
|
// 列表空态/异步按钮占位
|
||||||
|
listEmpty: '📭 还没有灵感,点击右上角「捕捉灵感」开始记录',
|
||||||
|
creating: '创建中…',
|
||||||
|
deleting: '删除中…',
|
||||||
|
promoting: '立项中…',
|
||||||
|
|
||||||
// 对抗式评估
|
// 对抗式评估
|
||||||
adversarialTitle: '⚖️ 对抗式评估',
|
adversarialTitle: '⚖️ 对抗式评估',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function stripMd(text: string | null | undefined): string {
|
|||||||
return text
|
return text
|
||||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
|
||||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
|
||||||
.replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开
|
.replace(/```[^\n]*\n?/g, '') // 代码块围栏开(含任意 infostring 至换行,如 rust,norun)
|
||||||
.replace(/```/g, '') // 代码块围栏闭
|
.replace(/```/g, '') // 代码块围栏闭
|
||||||
.replace(/`([^`]+)`/g, '$1') // 行内代码
|
.replace(/`([^`]+)`/g, '$1') // 行内代码
|
||||||
.replace(/^\s{0,3}>\s?/gm, '') // 引用
|
.replace(/^\s{0,3}>\s?/gm, '') // 引用
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function formatDate(v: string | number | null | undefined): string {
|
|||||||
const ms = parseTs(v)
|
const ms = parseTs(v)
|
||||||
if (ms == null) return '—'
|
if (ms == null) return '—'
|
||||||
const d = new Date(ms)
|
const d = new Date(ms)
|
||||||
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
const locale = (i18n as any).global.locale.value === 'en' ? 'en-US' : 'zh-CN'
|
||||||
|
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */
|
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-header-right">
|
<div class="dash-header-right">
|
||||||
<button class="df-btn df-btn--ghost" @click="refresh">
|
<button class="df-btn df-btn--ghost" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="df-btn df-btn--primary" @click="quickCapture">
|
<button class="df-btn df-btn--primary" @click="quickCapture">
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══ 错误条:refresh/onMounted 加载失败对用户可见(对齐 Knowledge error-banner) ═══ -->
|
||||||
|
<div v-if="errorMsg" class="error-banner">
|
||||||
|
<span class="error-text">{{ errorMsg }}</span>
|
||||||
|
<button class="error-dismiss" @click="errorMsg = ''">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Stat Cards ═══ -->
|
<!-- ═══ Stat Cards ═══ -->
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div
|
<div
|
||||||
@@ -104,7 +110,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
@@ -114,6 +120,10 @@ const router = useRouter()
|
|||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 加载态(refresh/load 期间 true,驱动刷新按钮禁用) + 错误态(加载失败对用户可见)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
const stats = computed(() => [
|
const stats = computed(() => [
|
||||||
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
||||||
iconBg: 'rgba(123,111,240,0.12)' },
|
iconBg: 'rgba(123,111,240,0.12)' },
|
||||||
@@ -179,24 +189,32 @@ function ideaStatusLabel(status: string): string {
|
|||||||
return t('dashboard.ideaStatus.' + status)
|
return t('dashboard.ideaStatus.' + status)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
// 统一加载入口(projects/tasks/ideas 并行),管理 loading + errorMsg
|
||||||
|
// refresh 与 onMounted 共用,避免两处 catch 逻辑重复
|
||||||
|
async function loadAll() {
|
||||||
|
if (loading.value) return // 防重复触发
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
try {
|
try {
|
||||||
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('Dashboard refresh failed:', e)
|
console.error('Dashboard load failed:', e)
|
||||||
|
errorMsg.value = e?.toString() ?? t('dashboard.err.loadFailed')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
function quickCapture() {
|
function quickCapture() {
|
||||||
router.push('/ideas')
|
router.push('/ideas')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
try {
|
loadAll()
|
||||||
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Dashboard load failed:', e)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -271,6 +289,21 @@ onMounted(async () => {
|
|||||||
border-color: var(--df-border-strong);
|
border-color: var(--df-border-strong);
|
||||||
}
|
}
|
||||||
.df-btn--xs { padding: 4px 10px; font-size: 11px; }
|
.df-btn--xs { padding: 4px 10px; font-size: 11px; }
|
||||||
|
.df-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ═══ 错误条(对齐 Knowledge error-banner) ═══ */
|
||||||
|
.error-banner {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 8px 12px; margin-bottom: 14px;
|
||||||
|
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
|
||||||
|
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
|
||||||
|
}
|
||||||
|
.error-text { word-break: break-word; }
|
||||||
|
.error-dismiss {
|
||||||
|
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
|
||||||
|
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.error-dismiss:hover { opacity: 0.7; }
|
||||||
|
|
||||||
/* ═══ Stat Cards ═══ */
|
/* ═══ Stat Cards ═══ */
|
||||||
.stat-row {
|
.stat-row {
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
<!-- 左侧:灵感列表 -->
|
<!-- 左侧:灵感列表 -->
|
||||||
<section class="idea-list-panel">
|
<section class="idea-list-panel">
|
||||||
<div class="idea-list">
|
<div class="idea-list">
|
||||||
|
<!-- 三态:加载/错误/空(对齐 Tasks.vue:43-45) -->
|
||||||
|
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||||||
|
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
|
||||||
|
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="idea in filteredIdeas"
|
v-for="idea in filteredIdeas"
|
||||||
:key="idea.id"
|
:key="idea.id"
|
||||||
@@ -50,6 +55,7 @@
|
|||||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -167,11 +173,14 @@
|
|||||||
<button
|
<button
|
||||||
v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to"
|
v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
:disabled="promoting"
|
||||||
@click="promoteToProject"
|
@click="promoteToProject"
|
||||||
>
|
>
|
||||||
{{ $t('ideas.promoteToProject') }}
|
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" :disabled="deleting" @click="deleteCurrentIdea">
|
||||||
|
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -195,7 +204,9 @@
|
|||||||
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn-confirm" @click="confirmCapture">{{ $t('common.confirm') }}</button>
|
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
|
||||||
|
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,6 +249,11 @@ const showCaptureModal = ref(false)
|
|||||||
const newIdeaTitle = ref('')
|
const newIdeaTitle = ref('')
|
||||||
const newIdeaDesc = ref('')
|
const newIdeaDesc = ref('')
|
||||||
|
|
||||||
|
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
|
||||||
|
const creating = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const promoting = ref(false)
|
||||||
|
|
||||||
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
||||||
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
|
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
|
||||||
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
||||||
@@ -383,23 +399,34 @@ function openCaptureModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function confirmCapture() {
|
async function confirmCapture() {
|
||||||
if (!newIdeaTitle.value.trim()) return
|
if (!newIdeaTitle.value.trim() || creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
await store.createIdea({
|
await store.createIdea({
|
||||||
title: newIdeaTitle.value.trim(),
|
title: newIdeaTitle.value.trim(),
|
||||||
description: newIdeaDesc.value.trim() || undefined,
|
description: newIdeaDesc.value.trim() || undefined,
|
||||||
})
|
})
|
||||||
showCaptureModal.value = false
|
showCaptureModal.value = false
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCurrentIdea() {
|
async function deleteCurrentIdea() {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value || deleting.value) return
|
||||||
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
await store.deleteIdea(currentIdea.value.id)
|
await store.deleteIdea(currentIdea.value.id)
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promoteToProject() {
|
async function promoteToProject() {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value || promoting.value) return
|
||||||
|
promoting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await store.promoteIdea(currentIdea.value.id)
|
const res = await store.promoteIdea(currentIdea.value.id)
|
||||||
router.push(`/projects/${res.project_id}`)
|
router.push(`/projects/${res.project_id}`)
|
||||||
@@ -407,6 +434,8 @@ async function promoteToProject() {
|
|||||||
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
||||||
console.error(t('ideas.promoteFailed'), e)
|
console.error(t('ideas.promoteFailed'), e)
|
||||||
Message.error(msg)
|
Message.error(msg)
|
||||||
|
} finally {
|
||||||
|
promoting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,14 +96,14 @@
|
|||||||
<div class="detail-actions" v-if="!editing">
|
<div class="detail-actions" v-if="!editing">
|
||||||
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
|
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
|
||||||
<template v-if="topTab === 'inbox'">
|
<template v-if="topTab === 'inbox'">
|
||||||
<button class="btn btn-primary btn-sm" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
|
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
|
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn btn-ghost btn-sm" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
|
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-actions" v-else>
|
<div class="detail-actions" v-else>
|
||||||
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
|
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="!editForm.title.trim()">{{ $t('common.save') }}</button>
|
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="submitting || !editForm.title.trim()">{{ $t('common.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
|
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!form.title.trim()">{{ $t('knowledge.create') }}</button>
|
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !form.title.trim()">{{ $t('knowledge.create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,6 +349,9 @@ watch(listItems, items => {
|
|||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
|
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
|
||||||
|
|
||||||
|
// 异步操作禁用态(IPC 期间防双击重复提交):saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
const k = detail.value.knowledge
|
const k = detail.value.knowledge
|
||||||
@@ -367,6 +370,8 @@ function cancelEdit() {
|
|||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!detail.value || !editForm.value.title.trim()) return
|
if (!detail.value || !editForm.value.title.trim()) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
await store.update(detail.value.knowledge.id, {
|
await store.update(detail.value.knowledge.id, {
|
||||||
title: editForm.value.title.trim(),
|
title: editForm.value.title.trim(),
|
||||||
@@ -377,28 +382,46 @@ async function saveEdit() {
|
|||||||
editing.value = false
|
editing.value = false
|
||||||
// 重新拉详情
|
// 重新拉详情
|
||||||
await selectKnowledge(detail.value.knowledge.id)
|
await selectKnowledge(detail.value.knowledge.id)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 审核操作(收件箱) =====
|
// ===== 审核操作(收件箱) =====
|
||||||
async function publishCurrent() {
|
async function publishCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
await store.updateStatus(detail.value.knowledge.id, 'published')
|
await store.updateStatus(detail.value.knowledge.id, 'published')
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
detail.value = null
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rejectCurrent() {
|
async function rejectCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
await store.archive(detail.value.knowledge.id)
|
await store.archive(detail.value.knowledge.id)
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
detail.value = null
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveCurrent() {
|
async function archiveCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
await store.archive(detail.value.knowledge.id)
|
await store.archive(detail.value.knowledge.id)
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
detail.value = null
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 新增对话框 =====
|
// ===== 新增对话框 =====
|
||||||
@@ -413,6 +436,8 @@ function closeCreateModal() {
|
|||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
}
|
}
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
await store.create({
|
await store.create({
|
||||||
kind: form.value.kind,
|
kind: form.value.kind,
|
||||||
@@ -423,6 +448,9 @@ async function submitCreate() {
|
|||||||
})
|
})
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
switchTopTab('inbox')
|
switchTopTab('inbox')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 事件解析辅助 =====
|
// ===== 事件解析辅助 =====
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
|
<button class="btn btn-primary" @click="submitNewTask" :disabled="submitting || !newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +208,7 @@
|
|||||||
:key="idx"
|
:key="idx"
|
||||||
class="btn"
|
class="btn"
|
||||||
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
|
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
|
||||||
|
:disabled="submitting"
|
||||||
@click="handleApproval(option)"
|
@click="handleApproval(option)"
|
||||||
>
|
>
|
||||||
{{ option }}
|
{{ option }}
|
||||||
@@ -319,8 +320,13 @@ const newTaskTitle = ref('')
|
|||||||
const newTaskDesc = ref('')
|
const newTaskDesc = ref('')
|
||||||
const newTaskBranch = ref('')
|
const newTaskBranch = ref('')
|
||||||
|
|
||||||
|
// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
async function submitNewTask() {
|
async function submitNewTask() {
|
||||||
if (!newTaskTitle.value.trim()) return
|
if (!newTaskTitle.value.trim()) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const r = await store.createTask({
|
const r = await store.createTask({
|
||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
title: newTaskTitle.value.trim(),
|
title: newTaskTitle.value.trim(),
|
||||||
@@ -332,6 +338,9 @@ async function submitNewTask() {
|
|||||||
newTaskTitle.value = ''
|
newTaskTitle.value = ''
|
||||||
newTaskDesc.value = ''
|
newTaskDesc.value = ''
|
||||||
newTaskBranch.value = ''
|
newTaskBranch.value = ''
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 同步 ──
|
// ── 同步 ──
|
||||||
@@ -406,8 +415,13 @@ async function handleImportDir() {
|
|||||||
|
|
||||||
// ── 审批处理 ──
|
// ── 审批处理 ──
|
||||||
async function handleApproval(decision: string) {
|
async function handleApproval(decision: string) {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
await store.approveHumanApproval([decision])
|
await store.approveHumanApproval([decision])
|
||||||
showApprovalDialog.value = false
|
showApprovalDialog.value = false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// F-260615-01: 多选审批
|
// F-260615-01: 多选审批
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
|
||||||
|
<div v-if="store.error" class="error-banner">
|
||||||
|
<span class="error-text">{{ store.error }}</span>
|
||||||
|
<button class="error-dismiss" @click="store.clearError()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新建项目模态框 -->
|
<!-- 新建项目模态框 -->
|
||||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button>
|
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !newName.trim()">{{ $t('projects.confirmCreate') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,6 +221,8 @@ const pathWarning = ref('')
|
|||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
const scanError = ref('')
|
const scanError = ref('')
|
||||||
const scanProjectType = ref('')
|
const scanProjectType = ref('')
|
||||||
|
// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
// 选择目录 → 探测技术栈 + 查重
|
// 选择目录 → 探测技术栈 + 查重
|
||||||
async function pickDir() {
|
async function pickDir() {
|
||||||
@@ -267,6 +275,9 @@ async function aiScan() {
|
|||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
if (!newName.value.trim()) return
|
if (!newName.value.trim()) return
|
||||||
if (pathWarning.value) return // 目录冲突,禁止创建
|
if (pathWarning.value) return // 目录冲突,禁止创建
|
||||||
|
if (submitting.value) return // 防双击重复提交
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
|
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
|
||||||
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
|
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
|
||||||
if (!r) return // 失败已 toast,保持弹窗不关
|
if (!r) return // 失败已 toast,保持弹窗不关
|
||||||
@@ -274,6 +285,9 @@ async function submitCreate() {
|
|||||||
newName.value = ''
|
newName.value = ''
|
||||||
newDesc.value = ''
|
newDesc.value = ''
|
||||||
clearDir()
|
clearDir()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 删除 / 回收站 ──
|
// ── 删除 / 回收站 ──
|
||||||
@@ -603,6 +617,20 @@ async function runImport() {
|
|||||||
font-size: 14px; color: var(--df-text-dim);
|
font-size: 14px; color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
|
||||||
|
.error-banner {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 8px 12px; margin-bottom: var(--df-gap-page);
|
||||||
|
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
|
||||||
|
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
|
||||||
|
}
|
||||||
|
.error-text { word-break: break-word; }
|
||||||
|
.error-dismiss {
|
||||||
|
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
|
||||||
|
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.error-dismiss:hover { opacity: 0.7; }
|
||||||
|
|
||||||
/* ===== 卡片删除按钮 + 回收站 ===== */
|
/* ===== 卡片删除按钮 + 回收站 ===== */
|
||||||
.card-top-actions { display: flex; align-items: center; gap: 8px; }
|
.card-top-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.card-del-btn {
|
.card-del-btn {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
||||||
<button class="btn-confirm" @click="confirmCreate">{{ $t('common.confirm') }}</button>
|
<button class="btn-confirm" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +133,9 @@ const newTaskDesc = ref('')
|
|||||||
const newTaskBranch = ref('')
|
const newTaskBranch = ref('')
|
||||||
const newTaskPriority = ref(2) // 默认 medium
|
const newTaskPriority = ref(2) // 默认 medium
|
||||||
|
|
||||||
|
// 异步操作禁用态(IPC 期间防双击重复提交):confirmCreate 创建任务期间禁用确认按钮
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
interface TaskGroup {
|
interface TaskGroup {
|
||||||
projectName: string
|
projectName: string
|
||||||
icon: string
|
icon: string
|
||||||
@@ -217,6 +220,8 @@ function openCreateModal() {
|
|||||||
|
|
||||||
async function confirmCreate() {
|
async function confirmCreate() {
|
||||||
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
|
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const r = await store.createTask({
|
const r = await store.createTask({
|
||||||
project_id: newTaskProjectId.value,
|
project_id: newTaskProjectId.value,
|
||||||
title: newTaskTitle.value.trim(),
|
title: newTaskTitle.value.trim(),
|
||||||
@@ -226,6 +231,9 @@ async function confirmCreate() {
|
|||||||
})
|
})
|
||||||
if (!r) return // 失败已 toast,保持弹窗不关
|
if (!r) return // 失败已 toast,保持弹窗不关
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)
|
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)
|
||||||
|
|||||||
Reference in New Issue
Block a user