From 61cf452d5cf8ce74c30d989d8b8d05b3137a66a5 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Thu, 2 Jul 2026 16:59:31 +0800 Subject: [PATCH 01/10] feat(planning): add plan approval gate before execution Insert an approval step between planning Phase A and execution Phase B. When planning is enabled, the assistant pauses after producing a plan so the user can run it or reject it; rejecting refills the composer with the original prompt so the user can refine and resend (the prompt stays the single source of intent, avoiding conflict with goal stability). - PlanApprovalState (oneshot + RAII guard) mirroring ToolApprovalState - respond_plan_approval command; planning_approval_enabled config (default on) - emit llm:plan-approval-resolved so the card is dismissed on any resolution (approve/reject/timeout/cancel), not just button clicks - AiPlanApprovalDialog UI + settings toggle + i18n - KNOWFORGE_DEBUG_PLANNING logs the final plan before Phase B --- src-tauri/src/lib.rs | 2 + src-tauri/src/llm/mod.rs | 27 ++++ src-tauri/src/llm/plan_approval.rs | 149 ++++++++++++++++++++++ src-tauri/src/llm/planning.rs | 103 ++++++++++++++++ src-tauri/src/vault_config.rs | 18 +++ src/components/AiConversationPanel.tsx | 53 +++++++- src/components/AiLlmSettingsModal.tsx | 28 +++++ src/components/AiPlanApprovalDialog.css | 156 ++++++++++++++++++++++++ src/components/AiPlanApprovalDialog.tsx | 73 +++++++++++ src/locales/en.json | 6 + src/locales/zh.json | 6 + src/types/toolTypes.ts | 12 ++ src/types/vaultAiConfig.ts | 2 + src/utils/toolInvoke.ts | 13 ++ 14 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/llm/plan_approval.rs create mode 100644 src/components/AiPlanApprovalDialog.css create mode 100644 src/components/AiPlanApprovalDialog.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c4712f..293e949 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1712,6 +1712,7 @@ pub fn run() { .manage(Arc::new(semantic_index::EmbeddingCache::new())) .manage(Arc::new(llm::LlmSessionState::default())) .manage(Arc::new(llm::approval::ToolApprovalState::new())) + .manage(Arc::new(llm::plan_approval::PlanApprovalState::new())) .manage(Arc::new(tools::ToolRegistry::new())) .manage(Arc::new(skills::SkillRegistry::new())) .manage(Arc::new(tokio::sync::Semaphore::new(skills::SKILL_CONCURRENCY))) @@ -1747,6 +1748,7 @@ pub fn run() { llm::start_chat_stream, llm::abort_llm_stream, llm::respond_tool_approval, + llm::respond_plan_approval, llm::clear_conversation_approvals, list_ai_conversations, create_ai_conversation, diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 2fd4d27..4d8b41d 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod agent_loop; pub mod approval; +pub mod plan_approval; pub(crate) mod context_guard; pub(crate) mod planning; pub(crate) mod provider; @@ -809,6 +810,7 @@ pub async fn start_chat_stream( registry: State<'_, Arc>, ctx_factory: State<'_, Arc>, approval: State<'_, Arc>, + plan_approval: State<'_, Arc>, skills: State<'_, Arc>, http_client: State<'_, Arc>, embed_cache_state: State<'_, Arc>, @@ -882,6 +884,7 @@ pub async fn start_chat_stream( let registry_arc = Arc::clone(registry.inner()); let ctx_factory_arc = Arc::clone(ctx_factory.inner()); let approval_arc = Arc::clone(approval.inner()); + let plan_approval_arc = Arc::clone(plan_approval.inner()); let workspace_root = root.clone(); let conversation_id = args .conversation_id @@ -1039,6 +1042,8 @@ pub async fn start_chat_stream( loop_config, conversation_id, approval_arc, + plan_approval_arc, + ai.planning_approval_enabled, memory_manager.clone(), ) .await; @@ -1239,6 +1244,28 @@ pub fn respond_tool_approval( approval.resolve(&args.approval_id, args.decision) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RespondPlanApprovalArgs { + pub approval_id: String, + /// "approve" | "reject" + pub decision: String, +} + +/// 前端响应一次计划审批请求(执行 / 拒绝)。 +#[tauri::command] +pub fn respond_plan_approval( + args: RespondPlanApprovalArgs, + plan_approval: State<'_, Arc>, +) -> Result<(), String> { + let decision = match args.decision.as_str() { + "approve" => plan_approval::PlanDecision::Approve, + "reject" => plan_approval::PlanDecision::Reject, + other => return Err(format!("unknown plan approval decision: {other}")), + }; + plan_approval.resolve(&args.approval_id, decision) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClearConversationApprovalsArgs { diff --git a/src-tauri/src/llm/plan_approval.rs b/src-tauri/src/llm/plan_approval.rs new file mode 100644 index 0000000..409aef6 --- /dev/null +++ b/src-tauri/src/llm/plan_approval.rs @@ -0,0 +1,149 @@ +//! 计划审批状态(Phase A→B 之间的门控)。 +//! +//! `run_planned_agent` 在 Phase A 生成计划后调用 [`PlanApprovalState::register`] 拿到 +//! receiver + [`PlanApprovalPendingGuard`],emit `llm:plan-approval-request`,然后 +//! `tokio::time::timeout` + `tokio::select!` 等待。guard 在外层 cancel 导致 future 被 drop 时 +//! 自动 `discard_pending`,防止 sender 泄漏在 map 中。计划审批每次都问,不做会话级缓存。 +//! +//! 仅提供"执行 / 拒绝"两态:计划是提示词的派生预览,不可编辑——用户若不认可应拒绝并 +//! 修改提示词重发,保持"提示词是唯一意图来源",避免与目标稳定性等机制产生冲突。 + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use tokio::sync::oneshot; + +/// 前端对一次计划审批请求的决策。 +pub enum PlanDecision { + /// 按计划执行。 + Approve, + /// 拒绝执行,丢弃整个请求。 + Reject, +} + +pub struct PlanApprovalState { + pending: Mutex>>, +} + +/// RAII 守卫:drop 时清理 pending 条目,避免外层 cancel 把 sender 留在 map 里。 +/// `discard_pending` 对不存在的 id 是 no-op,所以 happy path(resolve 已 remove)也安全。 +pub struct PlanApprovalPendingGuard { + state: Arc, + approval_id: String, +} + +impl Drop for PlanApprovalPendingGuard { + fn drop(&mut self) { + self.state.discard_pending(&self.approval_id); + } +} + +impl Default for PlanApprovalState { + fn default() -> Self { + Self::new() + } +} + +impl PlanApprovalState { + pub fn new() -> Self { + Self { + pending: Mutex::new(HashMap::new()), + } + } + + /// 登记一个 pending 审批,返回 (approval_id, receiver, guard)。 + /// approval_id 用 UUID v4 生成,无序无关联以防泄漏其他元信息。 + /// guard 需由调用方持有直到 await 结束。 + pub fn register( + self: &Arc, + ) -> (String, oneshot::Receiver, PlanApprovalPendingGuard) { + let approval_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + { + let mut g = match self.pending.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + g.insert(approval_id.clone(), tx); + } + let guard = PlanApprovalPendingGuard { + state: self.clone(), + approval_id: approval_id.clone(), + }; + (approval_id, rx, guard) + } + + /// 前端响应:取出 sender 并 send。若 id 不存在或 receiver 已 drop,返回 Err。 + pub fn resolve(&self, approval_id: &str, decision: PlanDecision) -> Result<(), String> { + let tx = { + let mut g = match self.pending.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + g.remove(approval_id) + .ok_or_else(|| format!("unknown approval_id: {approval_id}"))? + }; + tx.send(decision) + .map_err(|_| "approval receiver dropped (timed out or cancelled)".to_string()) + } + + /// 超时或取消时清理 pending 项(receiver 已 drop,sender 留在 map 里会泄漏)。 + pub fn discard_pending(&self, approval_id: &str) { + let mut g = match self.pending.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + g.remove(approval_id); + } + + #[cfg(test)] + fn pending_len(&self) -> usize { + let g = match self.pending.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + g.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn register_then_resolve_delivers_approve() { + let state = Arc::new(PlanApprovalState::new()); + let (id, rx, _guard) = state.register(); + state.resolve(&id, PlanDecision::Approve).unwrap(); + assert!(matches!(rx.await.unwrap(), PlanDecision::Approve)); + } + + #[tokio::test] + async fn resolve_delivers_reject() { + let state = Arc::new(PlanApprovalState::new()); + let (id, rx, _guard) = state.register(); + state.resolve(&id, PlanDecision::Reject).unwrap(); + assert!(matches!(rx.await.unwrap(), PlanDecision::Reject)); + } + + #[tokio::test] + async fn resolve_unknown_id_errors() { + let state = PlanApprovalState::new(); + let err = state.resolve("nope", PlanDecision::Approve).unwrap_err(); + assert!(err.contains("unknown")); + } + + #[tokio::test] + async fn guard_drop_cleans_pending_and_resolve_fails() { + let state = Arc::new(PlanApprovalState::new()); + assert_eq!(state.pending_len(), 0); + let id = { + let (id, _rx, _guard) = state.register(); + assert_eq!(state.pending_len(), 1); + id + }; + assert_eq!(state.pending_len(), 0); + let err = state.resolve(&id, PlanDecision::Approve).unwrap_err(); + assert!(err.contains("unknown")); + } +} diff --git a/src-tauri/src/llm/planning.rs b/src-tauri/src/llm/planning.rs index d7c2256..8e65648 100644 --- a/src-tauri/src/llm/planning.rs +++ b/src-tauri/src/llm/planning.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter}; @@ -7,11 +8,17 @@ use tokio_util::sync::CancellationToken; use super::agent_loop::{self, AgentLoopConfig, SharedMemoryManager}; use super::approval::ToolApprovalState; +use super::plan_approval::{PlanApprovalState, PlanDecision}; use super::provider::LlmProvider; use super::LlmChatMessage; use crate::tools::context::ToolContextFactory; use crate::tools::registry::ToolRegistry; +/// How long to wait for the user to act on a plan-approval request before +/// degrading to auto-execution. Longer than the per-tool approval timeout +/// (120s) since reviewing/editing a full plan takes more time. +const PLAN_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); + const PLANNING_SYSTEM_PROMPT: &str = "\ Analyze the user's request carefully. Output a numbered plan listing the exact tool calls \ you would make and why. Do NOT call any tools — only output the plan as plain text.\n\n\ @@ -85,6 +92,65 @@ pub(crate) fn emit_planning_done(app: &AppHandle, session_id: &str, plan_text: & ); } +/// Emit `llm:agent-done` when we short-circuit before the agent loop runs +/// (e.g. the user rejected the plan), so the frontend clears its streaming state. +fn emit_agent_done(app: &AppHandle, session_id: &str) { + let _ = app.emit("llm:agent-done", json!({ "sessionId": session_id })); +} + +/// Emit when the approval gate concludes by ANY means (approve / reject / timeout / +/// cancel), so the frontend dismisses the approval card. Interactive resolutions +/// (button clicks) also clear it optimistically on the frontend; this covers the +/// non-interactive paths (timeout auto-execute, cancel) where no click happens. +fn emit_plan_approval_resolved(app: &AppHandle, session_id: &str) { + let _ = app.emit("llm:plan-approval-resolved", json!({ "sessionId": session_id })); +} + +/// Outcome of the plan-approval gate between Phase A and Phase B. +enum PlanGateOutcome { + /// Proceed to execution with the original plan. + Proceed, + /// User rejected the plan; abandon the request. + Reject, + /// Cancelled while waiting. + Cancelled, +} + +/// Emit `llm:plan-approval-request` and wait for the user's decision. +/// Timeout or a closed channel degrades to executing the plan — +/// rejecting on timeout would silently drop the whole user request. +async fn wait_for_plan_approval( + app: &AppHandle, + session_id: &str, + conversation_id: &str, + state: &Arc, + plan_text: &str, + cancel: &CancellationToken, +) -> PlanGateOutcome { + let (approval_id, rx, _guard) = state.register(); + let _ = app.emit( + "llm:plan-approval-request", + json!({ + "sessionId": session_id, + "conversationId": conversation_id, + "approvalId": approval_id, + "planText": plan_text, + }), + ); + + let decision = tokio::select! { + res = tokio::time::timeout(PLAN_APPROVAL_TIMEOUT, rx) => res, + _ = cancel.cancelled() => return PlanGateOutcome::Cancelled, + }; + + match decision { + Ok(Ok(PlanDecision::Approve)) => PlanGateOutcome::Proceed, + Ok(Ok(PlanDecision::Reject)) => PlanGateOutcome::Reject, + // Elapsed (Err) or sender dropped (Ok(Err)): degrade to auto-execute. + Ok(Err(_)) | Err(_) => PlanGateOutcome::Proceed, + } +} + #[allow(clippy::too_many_arguments)] pub async fn run_planned_agent( app: AppHandle, @@ -101,6 +167,8 @@ pub async fn run_planned_agent( config: AgentLoopConfig, conversation_id: String, approval_state: Arc, + plan_approval_state: Arc, + planning_approval_enabled: bool, memory_manager: SharedMemoryManager, ) -> String { // Phase A: Planning (no tools, text-only output) @@ -130,7 +198,42 @@ pub async fn run_planned_agent( return String::new(); } + // Plan-approval gate (between Phase A and Phase B). Skipped when disabled or + // when there is no real plan to review (too short → Phase A likely failed). + if planning_approval_enabled && plan_text.trim().len() >= MIN_PLAN_LENGTH { + let outcome = wait_for_plan_approval( + &app, + &session_id, + &conversation_id, + &plan_approval_state, + &plan_text, + &cancel, + ) + .await; + + // Dismiss the approval card no matter how the gate resolved — including + // timeout/cancel, where the user never clicked and the frontend would + // otherwise leave the card up covering the execution output. + emit_plan_approval_resolved(&app, &session_id); + + match outcome { + PlanGateOutcome::Proceed => {} + PlanGateOutcome::Reject => { + emit_agent_done(&app, &session_id); + return String::new(); + } + PlanGateOutcome::Cancelled => return String::new(), + } + } + // Phase B: Execution (inject plan, normal agent loop) + if std::env::var("KNOWFORGE_DEBUG_PLANNING").is_ok() { + eprintln!( + "[planning] Phase B plan_text ({} chars):\n{}\n[planning] --- end plan ---", + plan_text.trim().len(), + plan_text.trim() + ); + } let exec_messages = if plan_text.trim().len() >= MIN_PLAN_LENGTH { inject_plan_into_messages(&initial_messages, &plan_text) } else { diff --git a/src-tauri/src/vault_config.rs b/src-tauri/src/vault_config.rs index 5536002..619190a 100644 --- a/src-tauri/src/vault_config.rs +++ b/src-tauri/src/vault_config.rs @@ -223,6 +223,11 @@ pub struct AiConfig { pub tools_enabled: bool, #[serde(default)] pub planning_enabled: bool, + /// Gate the plan-approval step between Phase A (plan) and Phase B (execute). + /// Defaults to true so planning users review plans before execution; only + /// takes effect when `planning_enabled` is true. + #[serde(default = "default_planning_approval_enabled")] + pub planning_approval_enabled: bool, #[serde(default = "default_memory_enabled")] pub memory_enabled: bool, #[serde(default = "default_memory_reflection_mode")] @@ -255,6 +260,10 @@ fn default_tools_enabled() -> bool { true } +fn default_planning_approval_enabled() -> bool { + true +} + fn default_memory_enabled() -> bool { true } @@ -276,6 +285,7 @@ impl Default for AiConfig { privacy: AiPrivacy::default(), tools_enabled: true, planning_enabled: false, + planning_approval_enabled: true, memory_enabled: true, memory_reflection_mode: default_memory_reflection_mode(), } @@ -512,6 +522,7 @@ struct AiDiskPartial { privacy: Option, tools_enabled: Option, planning_enabled: Option, + planning_approval_enabled: Option, memory_enabled: Option, memory_reflection_mode: Option, } @@ -669,6 +680,7 @@ pub struct AiConfigPatch { pub privacy: Option, pub tools_enabled: Option, pub planning_enabled: Option, + pub planning_approval_enabled: Option, pub memory_enabled: Option, pub memory_reflection_mode: Option, } @@ -804,6 +816,7 @@ pub struct AiConfigForUi { pub privacy: AiPrivacy, pub tools_enabled: bool, pub planning_enabled: bool, + pub planning_approval_enabled: bool, pub memory_enabled: bool, pub memory_reflection_mode: String, } @@ -950,6 +963,9 @@ fn merge_ai_from_disk_partial(mut cfg: AiConfig, partial: AiDiskPartial) -> AiCo if let Some(v) = partial.planning_enabled { cfg.planning_enabled = v; } + if let Some(v) = partial.planning_approval_enabled { + cfg.planning_approval_enabled = v; + } if let Some(v) = partial.memory_enabled { cfg.memory_enabled = v; } @@ -1026,6 +1042,7 @@ fn to_ai_for_ui(ai: AiConfig) -> AiConfigForUi { privacy: ai.privacy, tools_enabled: ai.tools_enabled, planning_enabled: ai.planning_enabled, + planning_approval_enabled: ai.planning_approval_enabled, memory_enabled: ai.memory_enabled, memory_reflection_mode: ai.memory_reflection_mode, } @@ -1299,6 +1316,7 @@ fn apply_ai_patch(cfg: &mut AiConfig, patch: AiConfigPatch) { } if let Some(v) = patch.tools_enabled { cfg.tools_enabled = v; } if let Some(v) = patch.planning_enabled { cfg.planning_enabled = v; } + if let Some(v) = patch.planning_approval_enabled { cfg.planning_approval_enabled = v; } if let Some(v) = patch.memory_enabled { cfg.memory_enabled = v; } if let Some(v) = patch.memory_reflection_mode { cfg.memory_reflection_mode = v; } } diff --git a/src/components/AiConversationPanel.tsx b/src/components/AiConversationPanel.tsx index aaf5792..b66b57d 100644 --- a/src/components/AiConversationPanel.tsx +++ b/src/components/AiConversationPanel.tsx @@ -21,8 +21,9 @@ import { VAULT_CONFIG_UPDATED_EVENT } from "../utils/vaultConfigBroadcast"; import { markdownTreatAsKfPrivateForUi } from "../utils/kfPrivateMarkdown"; import { PrivacyChangeOverlay } from "./PrivacyChangeOverlay"; import { AiToolApprovalDialog } from "./AiToolApprovalDialog"; -import type { ApprovalRequest } from "../types/toolTypes"; -import { respondToolApproval } from "../utils/toolInvoke"; +import { AiPlanApprovalDialog } from "./AiPlanApprovalDialog"; +import type { ApprovalRequest, PlanApprovalRequest } from "../types/toolTypes"; +import { respondPlanApproval, respondToolApproval } from "../utils/toolInvoke"; import { listSkills, invokeSkill } from "../utils/skillInvoke"; import type { SkillManifestJson } from "../types/skillTypes"; import { parseSlashCommand } from "../utils/parseSlashCommand"; @@ -419,6 +420,38 @@ export function AiConversationPanel() { setActiveApproval(null); }, [conversationId]); + /** 计划审批:Planning 模式每个 run 只有一份计划,无需队列 */ + const [activePlanApproval, setActivePlanApproval] = + useState(null); + const handlePlanApprove = useCallback((approvalId: string) => { + void respondPlanApproval(approvalId, "approve").catch(() => {}); + setActivePlanApproval(null); + }, []); + const handlePlanReject = useCallback((approvalId: string) => { + void respondPlanApproval(approvalId, "reject").catch(() => {}); + setActivePlanApproval(null); + // 拒绝后把触发本次的提示词回填到输入框,方便用户修改后重发 + // (提示词是唯一意图来源,改提示词而非改计划)。 + setMessages((prev) => { + for (let i = prev.length - 1; i >= 0; i--) { + if (prev[i].role === "user") { + const lastUser = prev[i].content; + if (lastUser.length > 0) { + queueMicrotask(() => { + setInput(lastUser); + composerRef.current?.focus(); + }); + } + break; + } + } + return prev; + }); + }, []); + useEffect(() => { + setActivePlanApproval(null); + }, [conversationId]); + /** invite-after-answer 状态 */ const [inviteData, setInviteData] = useState(null); const [challengeInlineData, setChallengeInlineData] = useState(null); @@ -1086,6 +1119,17 @@ export function AiConversationPanel() { return next; }); }), + listen("llm:plan-approval-request", (e) => { + if (e.payload.sessionId !== activeSessionRef.current) return; + setActivePlanApproval(e.payload); + }), + // Gate resolved by any means (approve/reject/timeout/cancel) — dismiss the + // card. Covers non-interactive paths (timeout auto-execute) where the user + // never clicked, so the card would otherwise linger over the output. + listen<{ sessionId: string }>("llm:plan-approval-resolved", (e) => { + if (e.payload.sessionId !== activeSessionRef.current) return; + setActivePlanApproval(null); + }), listen<{ sessionId: string }>("llm:planning-start", (e) => { if (e.payload.sessionId !== activeSessionRef.current) return; setIsPlanning(true); @@ -2657,6 +2701,11 @@ export function AiConversationPanel() { ) : null} + ); } diff --git a/src/components/AiLlmSettingsModal.tsx b/src/components/AiLlmSettingsModal.tsx index d9cf649..d8b4414 100644 --- a/src/components/AiLlmSettingsModal.tsx +++ b/src/components/AiLlmSettingsModal.tsx @@ -144,6 +144,7 @@ type FormState = { allowPrivateContentInLocalLlm: boolean; toolsEnabled: boolean; planningEnabled: boolean; + planningApprovalEnabled: boolean; memoryEnabled: boolean; memoryReflectionMode: string; passiveHighlightEnabled: boolean; @@ -252,6 +253,7 @@ function defaultForm(): FormState { allowPrivateContentInLocalLlm: false, toolsEnabled: true, planningEnabled: false, + planningApprovalEnabled: true, memoryEnabled: true, memoryReflectionMode: "confirm", passiveHighlightEnabled: true, @@ -302,6 +304,7 @@ function aiFormEqualsPersisted(a: FormState, b: FormState): boolean { "allowPrivateContentInLocalLlm", "toolsEnabled", "planningEnabled", + "planningApprovalEnabled", "memoryEnabled", "memoryReflectionMode", "passiveHighlightEnabled", @@ -364,6 +367,7 @@ function vaultConfigToForm(cfg: VaultConfigForUi): FormState { allowPrivateContentInLocalLlm: ai.privacy.allowPrivateContentInLocalLlm, toolsEnabled: ai.toolsEnabled !== false, planningEnabled: ai.planningEnabled === true, + planningApprovalEnabled: ai.planningApprovalEnabled !== false, memoryEnabled: ai.memoryEnabled !== false, memoryReflectionMode: ai.memoryReflectionMode ?? "confirm", passiveHighlightEnabled: cognitive.passiveHighlightEnabled !== false, @@ -781,6 +785,7 @@ export function AiLlmSettingsModal({ }, toolsEnabled: form.toolsEnabled, planningEnabled: form.planningEnabled, + planningApprovalEnabled: form.planningApprovalEnabled, memoryEnabled: form.memoryEnabled, memoryReflectionMode: form.memoryReflectionMode, }, @@ -1299,6 +1304,29 @@ export function AiLlmSettingsModal({ {t("settings.planningEnabled")}

{t("settings.planningEnabledHint")}

+ {form.planningEnabled && ( + <> + +

+ {t("settings.planningApprovalEnabledHint")} +

+ + )}