mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
feat(pi): expose explicit prompt library reconciliation
This commit is contained in:
@@ -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<Option<Strin
|
||||
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pi_prompt_library_status(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PiPromptLibraryStatus, String> {
|
||||
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<PiPromptFileSnapshot, String> {
|
||||
PiPromptFileService::read(kind).map_err(|error| error.to_string())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<i64, AppError> {
|
||||
|
||||
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<String>,
|
||||
pub needs_reconciliation: bool,
|
||||
}
|
||||
|
||||
impl PromptService {
|
||||
pub fn get_prompts(
|
||||
state: &AppState,
|
||||
app: AppType,
|
||||
) -> Result<IndexMap<String, Prompt>, 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<PiPromptLibraryStatus, AppError> {
|
||||
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<String, Prompt>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PromptPanelHandle, PromptPanelProps>(
|
||||
const {
|
||||
prompts,
|
||||
loading,
|
||||
piLibraryStatus,
|
||||
reload,
|
||||
savePrompt,
|
||||
deletePrompt,
|
||||
toggleEnabled,
|
||||
reconcilePiLibrary,
|
||||
} = usePromptActions(appId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,6 +126,43 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{appId === "pi" && piLibraryStatus?.needsReconciliation && (
|
||||
<div
|
||||
className="mb-4 flex items-start justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("pi.prompts.libraryDriftTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(
|
||||
piLibraryStatus.nativeExists
|
||||
? "pi.prompts.libraryDriftNative"
|
||||
: "pi.prompts.libraryDriftMissing",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
void reconcilePiLibrary().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("pi.prompts.reconcileLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
|
||||
@@ -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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [piLibraryStatus, setPiLibraryStatus] =
|
||||
useState<PiPromptLibraryStatus | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "系统提示追加",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, Prompt>> {
|
||||
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<PiPromptLibraryStatus> {
|
||||
return await invoke("get_pi_prompt_library_status");
|
||||
},
|
||||
|
||||
async reconcilePiPromptLibrary(): Promise<void> {
|
||||
return await invoke("reconcile_pi_prompt_library");
|
||||
},
|
||||
|
||||
async getPiPromptFile(kind: PiPromptFileKind): Promise<PiPromptFileSnapshot> {
|
||||
return await invoke("get_pi_prompt_file", { kind });
|
||||
},
|
||||
|
||||
@@ -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: () => <div>native resources</div>,
|
||||
}));
|
||||
|
||||
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(<PromptPanel open appId="pi" onOpenChange={vi.fn()} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user