diff --git a/src-tauri/src/commands/prompt.rs b/src-tauri/src/commands/prompt.rs index 38cd13d65..8f0ebcdc9 100644 --- a/src-tauri/src/commands/prompt.rs +++ b/src-tauri/src/commands/prompt.rs @@ -9,7 +9,7 @@ use crate::services::pi_prompt_files::{ PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate, PiPromptTemplateService, }; -use crate::services::PromptService; +use crate::services::prompt::{PiPromptLibraryStatus, PromptService}; use crate::store::AppState; #[tauri::command] @@ -67,6 +67,18 @@ pub async fn get_current_prompt_file_content(app: String) -> Result, +) -> Result { + PromptService::get_pi_library_status(&state).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn reconcile_pi_prompt_library(state: State<'_, AppState>) -> Result<(), String> { + PromptService::reconcile_pi_library(&state).map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result { PiPromptFileService::read(kind).map_err(|error| error.to_string()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00831ef5e..c50a94e40 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1420,6 +1420,8 @@ pub fn run() { commands::enable_prompt, commands::import_prompt_from_file, commands::get_current_prompt_file_content, + commands::get_pi_prompt_library_status, + commands::reconcile_pi_prompt_library, commands::get_pi_prompt_file, commands::replace_pi_prompt_file, commands::delete_pi_prompt_file, diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index a1064abce..f184c62d2 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -11,6 +11,7 @@ use crate::services::pi_prompt_files::{ PiPromptFileSnapshot, }; use crate::store::AppState; +use serde::Serialize; use sha2::{Digest, Sha256}; /// 安全地获取当前 Unix 时间戳 @@ -23,14 +24,41 @@ fn get_unix_timestamp() -> Result { pub struct PromptService; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PiPromptLibraryStatus { + pub native_exists: bool, + pub native_revision: String, + pub matched_prompt_id: Option, + pub needs_reconciliation: bool, +} + impl PromptService { pub fn get_prompts( state: &AppState, app: AppType, ) -> Result, AppError> { + if matches!(app, AppType::Pi) { + let guard = lock_instruction_files()?; + return Self::inspect_pi_library_under_guard(state.db.as_ref(), &guard) + .map(|(prompts, _)| prompts); + } state.db.get_prompts(app.as_str()) } + /// Inspect Pi's live AGENTS.md without adopting it into the portable + /// library. File presence and exact bytes are the effective active truth; + /// persisted `enabled` flags are only a projection that explicit + /// reconciliation may repair. + pub fn get_pi_library_status(state: &AppState) -> Result { + let guard = lock_instruction_files()?; + Self::inspect_pi_library_under_guard(state.db.as_ref(), &guard).map(|(_, status)| status) + } + + pub fn reconcile_pi_library(state: &AppState) -> Result<(), AppError> { + Self::reconcile_pi_portable_import(state) + } + pub fn upsert_prompt( state: &AppState, app: AppType, @@ -371,6 +399,43 @@ impl PromptService { )) } + fn inspect_pi_library_under_guard( + db: &Database, + guard: &PiInstructionFileGuard, + ) -> Result<(IndexMap, PiPromptLibraryStatus), AppError> { + let snapshot = + PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?; + let mut prompts = db.get_prompts(AppType::Pi.as_str())?; + let persisted_enabled = prompts + .iter() + .filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone())) + .collect::>(); + let matched_prompt_id = if snapshot.exists { + prompts + .iter() + .find_map(|(id, prompt)| (prompt.content == snapshot.content).then(|| id.clone())) + } else { + None + }; + let expected_enabled = matched_prompt_id.iter().cloned().collect::>(); + let needs_reconciliation = persisted_enabled != expected_enabled + || (snapshot.exists && matched_prompt_id.is_none()); + + for (id, prompt) in &mut prompts { + prompt.enabled = matched_prompt_id.as_deref() == Some(id.as_str()); + } + + Ok(( + prompts, + PiPromptLibraryStatus { + native_exists: snapshot.exists, + native_revision: snapshot.revision, + matched_prompt_id, + needs_reconciliation, + }, + )) + } + fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> { let guard = lock_instruction_files()?; let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; @@ -736,6 +801,18 @@ mod tests { std::fs::write(temp.path().join("AGENTS.md"), "external-after").expect("external edit"); let prompts = PromptService::get_prompts(&state, AppType::Pi).expect("read prompt list"); assert_eq!(prompts["managed"].content, "managed-before"); + assert!( + prompts.values().all(|prompt| !prompt.enabled), + "read-only inspection must report native truth instead of the stale DB projection" + ); + assert!( + state + .db + .get_prompts(AppType::Pi.as_str()) + .expect("read persisted projection")["managed"] + .enabled, + "inspection must not silently adopt or rewrite the portable library" + ); assert!( PromptService::enable_prompt(&state, AppType::Pi, "other").is_err(), "the write boundary must still report the external drift conflict" @@ -745,4 +822,91 @@ mod tests { "external-after" ); } + + #[test] + #[serial] + fn explicit_pi_library_reconciliation_adopts_external_native_truth() { + let temp = tempfile::tempdir().expect("tempdir"); + let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path()); + std::fs::write(temp.path().join("AGENTS.md"), "external-after").expect("seed AGENTS.md"); + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + state + .db + .save_prompt( + AppType::Pi.as_str(), + &Prompt { + id: "stale".to_string(), + name: "Stale".to_string(), + content: "managed-before".to_string(), + description: None, + enabled: true, + created_at: Some(1), + updated_at: Some(1), + }, + ) + .expect("seed stale projection"); + + let status = PromptService::get_pi_library_status(&state).expect("inspect"); + assert!(status.native_exists); + assert!(status.matched_prompt_id.is_none()); + assert!(status.needs_reconciliation); + + PromptService::reconcile_pi_library(&state).expect("explicit reconcile"); + let prompts = PromptService::get_prompts(&state, AppType::Pi).expect("read reconciled"); + let active = prompts + .values() + .filter(|prompt| prompt.enabled) + .collect::>(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].content, "external-after"); + assert!( + !PromptService::get_pi_library_status(&state) + .expect("reinspect") + .needs_reconciliation + ); + assert_eq!( + std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("native remains"), + "external-after" + ); + } + + #[test] + #[serial] + fn missing_agents_file_is_effectively_inactive_until_explicit_reconciliation() { + let temp = tempfile::tempdir().expect("tempdir"); + let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path()); + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + state + .db + .save_prompt( + AppType::Pi.as_str(), + &Prompt { + id: "shadow".to_string(), + name: "Shadow".to_string(), + content: "not live".to_string(), + description: None, + enabled: true, + created_at: Some(1), + updated_at: Some(1), + }, + ) + .expect("seed shadow"); + + assert!(PromptService::get_prompts(&state, AppType::Pi) + .expect("read effective") + .values() + .all(|prompt| !prompt.enabled)); + let status = PromptService::get_pi_library_status(&state).expect("inspect"); + assert!(!status.native_exists); + assert!(status.needs_reconciliation); + + PromptService::reconcile_pi_library(&state).expect("explicit reconcile"); + assert!(state + .db + .get_prompts(AppType::Pi.as_str()) + .expect("read persisted") + .values() + .all(|prompt| !prompt.enabled)); + assert!(!temp.path().join("AGENTS.md").exists()); + } } diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 362293fe9..bd73ba052 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FileText } from "lucide-react"; +import { AlertTriangle, FileText, RefreshCw } from "lucide-react"; import { type AppId } from "@/lib/api"; import { usePromptActions } from "@/hooks/usePromptActions"; import { useTauriEvent } from "@/hooks/useTauriEvent"; @@ -8,6 +8,7 @@ import PromptListItem from "./PromptListItem"; import PromptFormPanel from "./PromptFormPanel"; import { PiNativePromptResources } from "./PiNativePromptResources"; import { ConfirmDialog } from "../ConfirmDialog"; +import { Button } from "@/components/ui/button"; interface PromptPanelProps { open: boolean; @@ -35,10 +36,12 @@ const PromptPanel = React.forwardRef( const { prompts, loading, + piLibraryStatus, reload, savePrompt, deletePrompt, toggleEnabled, + reconcilePiLibrary, } = usePromptActions(appId); useEffect(() => { @@ -123,6 +126,43 @@ const PromptPanel = React.forwardRef(

)} + {appId === "pi" && piLibraryStatus?.needsReconciliation && ( +
+
+
+ +
+ )} {loading ? (
{t("prompts.loading")} diff --git a/src/hooks/usePromptActions.ts b/src/hooks/usePromptActions.ts index 538d6ee40..6fd533a95 100644 --- a/src/hooks/usePromptActions.ts +++ b/src/hooks/usePromptActions.ts @@ -1,7 +1,12 @@ import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { promptsApi, type Prompt, type AppId } from "@/lib/api"; +import { + promptsApi, + type Prompt, + type AppId, + type PiPromptLibraryStatus, +} from "@/lib/api"; export function usePromptActions(appId: AppId) { const { t } = useTranslation(); @@ -10,12 +15,19 @@ export function usePromptActions(appId: AppId) { const [currentFileContent, setCurrentFileContent] = useState( null, ); + const [piLibraryStatus, setPiLibraryStatus] = + useState(null); const reload = useCallback(async () => { setLoading(true); try { const data = await promptsApi.getPrompts(appId); setPrompts(data); + if (appId === "pi") { + setPiLibraryStatus(await promptsApi.getPiPromptLibraryStatus()); + } else { + setPiLibraryStatus(null); + } // 同时加载当前文件内容 try { @@ -138,15 +150,28 @@ export function usePromptActions(appId: AppId) { } }, [appId, reload, t]); + const reconcilePiLibrary = useCallback(async () => { + try { + await promptsApi.reconcilePiPromptLibrary(); + await reload(); + toast.success(t("pi.prompts.libraryReconciled"), { closeButton: true }); + } catch (error) { + toast.error(t("pi.prompts.libraryReconcileFailed")); + throw error; + } + }, [reload, t]); + return { prompts, loading, currentFileContent, + piLibraryStatus, reload, savePrompt, deletePrompt, enablePrompt, toggleEnabled, importFromFile, + reconcilePiLibrary, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9f7db782b..0ad5d827d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -962,6 +962,12 @@ "nativeDescription": "File presence is activation. SYSTEM.md replaces Pi's system prompt, APPEND_SYSTEM.md appends to it, and /template-name expands files from the prompts directory.", "agentsLibrary": "AGENTS.md library", "agentsLibraryDescription": "The enabled library entry is projected to AGENTS.md. External edits are detected and must be imported before switching.", + "libraryDriftTitle": "AGENTS.md and the library differ", + "libraryDriftNative": "Pi is using native AGENTS.md content that is not the library's saved selection. Reconcile to import and select the exact live content.", + "libraryDriftMissing": "AGENTS.md is absent, so Pi is not using a global prompt even though the library still has an enabled selection.", + "reconcileLibrary": "Reconcile", + "libraryReconciled": "AGENTS.md library reconciled with native Pi state", + "libraryReconcileFailed": "Failed to reconcile the AGENTS.md library", "systemOverride": "System override", "systemOverrideDescription": "SYSTEM.md completely replaces Pi's built-in system prompt while the file exists.", "systemAppend": "System append", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 533cd7a0a..7950a2cc9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -962,6 +962,12 @@ "nativeDescription": "文件存在即启用。SYSTEM.md 替换 Pi 系统提示,APPEND_SYSTEM.md 追加系统提示,/模板名 会展开 prompts 目录中的文件。", "agentsLibrary": "AGENTS.md 提示库", "agentsLibraryDescription": "已启用的提示库条目会投影到 AGENTS.md;检测到外部修改时,必须先导入才能切换。", + "libraryDriftTitle": "AGENTS.md 与提示库不一致", + "libraryDriftNative": "Pi 正在使用的原生 AGENTS.md 内容不是提示库中保存的选中项。协调后会导入并选中完全一致的实时内容。", + "libraryDriftMissing": "AGENTS.md 不存在,因此 Pi 当前没有使用全局提示词,但提示库仍保留了启用项。", + "reconcileLibrary": "协调", + "libraryReconciled": "AGENTS.md 提示库已与 Pi 原生状态协调", + "libraryReconcileFailed": "协调 AGENTS.md 提示库失败", "systemOverride": "系统提示替换", "systemOverrideDescription": "SYSTEM.md 存在期间会完整替换 Pi 内置系统提示。", "systemAppend": "系统提示追加", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 09f8e5fb9..9156ac143 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -23,7 +23,7 @@ export * as configApi from "./config"; export * as authApi from "./auth"; export * as copilotApi from "./copilot"; export type { ProviderSwitchEvent } from "./providers"; -export type { Prompt } from "./prompts"; +export type { PiPromptLibraryStatus, Prompt } from "./prompts"; export type { Profile, ProfilePayload, ProfilesResponse } from "./profiles"; export type { CopilotDeviceCodeResponse, diff --git a/src/lib/api/prompts.ts b/src/lib/api/prompts.ts index cbc3797ce..04a10c121 100644 --- a/src/lib/api/prompts.ts +++ b/src/lib/api/prompts.ts @@ -30,6 +30,13 @@ export interface PiPromptTemplate { revision: string; } +export interface PiPromptLibraryStatus { + nativeExists: boolean; + nativeRevision: string; + matchedPromptId: string | null; + needsReconciliation: boolean; +} + export const promptsApi = { async getPrompts(app: AppId): Promise> { return await invoke("get_prompts", { app }); @@ -55,6 +62,14 @@ export const promptsApi = { return await invoke("get_current_prompt_file_content", { app }); }, + async getPiPromptLibraryStatus(): Promise { + return await invoke("get_pi_prompt_library_status"); + }, + + async reconcilePiPromptLibrary(): Promise { + return await invoke("reconcile_pi_prompt_library"); + }, + async getPiPromptFile(kind: PiPromptFileKind): Promise { return await invoke("get_pi_prompt_file", { kind }); }, diff --git a/tests/components/PromptPanel.piReconciliation.test.tsx b/tests/components/PromptPanel.piReconciliation.test.tsx new file mode 100644 index 000000000..d0d5b9b64 --- /dev/null +++ b/tests/components/PromptPanel.piReconciliation.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import PromptPanel from "@/components/prompts/PromptPanel"; + +const mocks = vi.hoisted(() => ({ + reconcilePiLibrary: vi.fn(), + reload: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/hooks/useTauriEvent", () => ({ + useTauriEvent: vi.fn(), +})); + +vi.mock("@/components/prompts/PiNativePromptResources", () => ({ + PiNativePromptResources: () =>
native resources
, +})); + +vi.mock("@/hooks/usePromptActions", () => ({ + usePromptActions: () => ({ + prompts: {}, + loading: false, + currentFileContent: "external native content", + piLibraryStatus: { + nativeExists: true, + nativeRevision: "native-revision", + matchedPromptId: null, + needsReconciliation: true, + }, + reload: mocks.reload, + savePrompt: vi.fn(), + deletePrompt: vi.fn(), + enablePrompt: vi.fn(), + toggleEnabled: vi.fn(), + importFromFile: vi.fn(), + reconcilePiLibrary: mocks.reconcilePiLibrary, + }), +})); + +describe("PromptPanel Pi native reconciliation", () => { + beforeEach(() => { + mocks.reconcilePiLibrary.mockReset(); + mocks.reconcilePiLibrary.mockResolvedValue(undefined); + mocks.reload.mockReset(); + }); + + it("shows native drift and exposes the explicit reconciliation action", () => { + render(); + + expect( + screen.getByText("pi.prompts.libraryDriftTitle"), + ).toBeInTheDocument(); + expect( + screen.getByText("pi.prompts.libraryDriftNative"), + ).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "pi.prompts.reconcileLibrary" }), + ); + expect(mocks.reconcilePiLibrary).toHaveBeenCalledTimes(1); + }); +});