feat(prompts): add JSON export/import for profile prompt configurations

This commit is contained in:
Woody 2026-04-27 19:44:35 +08:00
parent bb6b159315
commit 23796d6a0c
9 changed files with 585 additions and 20 deletions

View File

@ -27,3 +27,41 @@ class PromptBatchUpdateRequest(BaseModel):
class ResetToDefaultsRequest(BaseModel): class ResetToDefaultsRequest(BaseModel):
step: str | None = None step: str | None = None
# ── Export / Import models ─────────────────────────────────────────────────
class ProfileExportRequest(BaseModel):
format: str = "legco-reranker-profile/v1"
profile_name: str
exported_at: str | None = None
prompts: dict[str, str]
class ProfileExportResponse(BaseModel):
format: str
profile_name: str
exported_at: str
prompts: dict[str, str]
class AllProfilesExportResponse(BaseModel):
format: str
exported_at: str
active_profile: str
profiles: dict[str, dict]
class ProfileImportRequest(BaseModel):
format: str
profile_name: str
exported_at: str | None = None
prompts: dict[str, str]
class ProfileImportResponse(BaseModel):
status: str
profile: str
imported_steps: int
source_profile: str

View File

@ -1,6 +1,8 @@
import logging import logging
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from app.core.dependencies import get_prompt_service from app.core.dependencies import get_prompt_service
from app.models.prompts import ( from app.models.prompts import (
@ -10,6 +12,10 @@ from app.models.prompts import (
PromptUpdateRequest, PromptUpdateRequest,
PromptBatchUpdateRequest, PromptBatchUpdateRequest,
ResetToDefaultsRequest, ResetToDefaultsRequest,
ProfileExportResponse,
AllProfilesExportResponse,
ProfileImportRequest,
ProfileImportResponse,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,6 +44,63 @@ def _ensure_valid_step(step: str) -> None:
raise HTTPException(status_code=400, detail=f"Invalid step '{step}'. Must be one of decompose, filter, generate.") raise HTTPException(status_code=400, detail=f"Invalid step '{step}'. Must be one of decompose, filter, generate.")
_EXPORT_FORMAT = "legco-reranker-profile/v1"
@router.get("/export/all", response_model=AllProfilesExportResponse)
def export_all_profiles():
svc = get_prompt_service()
profiles_list = svc.list_profiles()
active_profile = svc.get_active_profile_name()
profiles_data: dict[str, dict] = {}
for p in profiles_list:
profiles_data[p["name"]] = {"prompts": svc.get_profile_prompts(p["name"])}
return AllProfilesExportResponse(
format=_EXPORT_FORMAT,
exported_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
active_profile=active_profile,
profiles=profiles_data,
)
@router.get("/profiles/{name}/export", response_model=ProfileExportResponse)
def export_profile(name: str):
_ensure_valid_name(name)
svc = get_prompt_service()
prompts = svc.get_profile_prompts(name)
return JSONResponse(
content=ProfileExportResponse(
format=_EXPORT_FORMAT,
profile_name=name,
exported_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
prompts=prompts,
).model_dump(),
headers={"Content-Disposition": f'attachment; filename="legco-profile-{name}.json"'},
)
@router.post("/profiles/{name}/import", response_model=ProfileImportResponse)
def import_profile(name: str, body: ProfileImportRequest):
_ensure_valid_name(name)
if body.format != _EXPORT_FORMAT:
raise HTTPException(status_code=400, detail=f"Unsupported format '{body.format}'. Expected '{_EXPORT_FORMAT}'.")
provided = set(body.prompts.keys())
missing = _VALID_STEPS - provided
if missing:
raise HTTPException(status_code=400, detail=f"Missing required steps: {', '.join(sorted(missing))}")
unknown = provided - _VALID_STEPS
if unknown:
raise HTTPException(status_code=400, detail=f"Unknown steps: {', '.join(sorted(unknown))}")
svc = get_prompt_service()
svc.update_all_prompts(name, body.prompts)
return ProfileImportResponse(
status="ok",
profile=name,
imported_steps=len(body.prompts),
source_profile=body.profile_name,
)
@router.get("/profiles", response_model=ProfileListResponse) @router.get("/profiles", response_model=ProfileListResponse)
def list_profiles(): def list_profiles():
svc = get_prompt_service() svc = get_prompt_service()

View File

@ -0,0 +1,88 @@
"""Tests for prompt profile export endpoints (Phase PX.1).
Covers:
- GET /api/v1/prompts/profiles/{name}/export single profile export
- GET /api/v1/prompts/export/all all profiles export
- Validation of profile name, format, and structure
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.sqlite_db import init_prompts_db, seed_default_profiles, _get_db
from app.routers.prompts import router
_VALID_STEPS = {
"decompose", "filter", "generate",
"generate_per_subq", "filter_intro", "filter_section", "filter_outro",
}
@pytest.fixture
def client(tmp_path, monkeypatch):
prompts_path = str(tmp_path / "prompts.db")
monkeypatch.setenv("PROMPTS_DB_PATH", prompts_path)
monkeypatch.setenv("HISTORY_DB_PATH", str(tmp_path / "history.db"))
from app.core.config import get_settings
get_settings.cache_clear()
from app.core.dependencies import get_settings_cached
get_settings_cached.cache_clear()
conn = _get_db(prompts_path)
init_prompts_db(conn)
seed_default_profiles(conn)
conn.close()
test_app = FastAPI()
test_app.include_router(router)
yield TestClient(test_app)
get_settings_cached.cache_clear()
get_settings.cache_clear()
def test_export_profile_valid(client):
resp = client.get("/api/v1/prompts/profiles/A/export")
assert resp.status_code == 200
data = resp.json()
assert data["format"] == "legco-reranker-profile/v1"
assert data["profile_name"] == "A"
assert "exported_at" in data
assert set(data["prompts"].keys()) == _VALID_STEPS
def test_export_profile_has_content_disposition_header(client):
resp = client.get("/api/v1/prompts/profiles/A/export")
assert resp.status_code == 200
assert resp.headers["content-disposition"] == 'attachment; filename="legco-profile-A.json"'
def test_export_profile_invalid_name(client):
resp = client.get("/api/v1/prompts/profiles/X/export")
assert resp.status_code == 400
def test_export_all(client):
resp = client.get("/api/v1/prompts/export/all")
assert resp.status_code == 200
data = resp.json()
assert data["format"] == "legco-reranker-profile/v1"
assert "exported_at" in data
assert data["active_profile"] == "A"
assert set(data["profiles"].keys()) == {"A", "B", "C"}
for name in ("A", "B", "C"):
assert set(data["profiles"][name]["prompts"].keys()) == _VALID_STEPS
def test_export_profile_b_and_c(client):
for name in ("B", "C"):
resp = client.get(f"/api/v1/prompts/profiles/{name}/export")
assert resp.status_code == 200
assert resp.json()["profile_name"] == name

View File

@ -0,0 +1,126 @@
"""Tests for prompt profile import endpoint (Phase PX.2).
Covers:
- POST /api/v1/prompts/profiles/{name}/import import with validation
- Format validation, step validation, profile name validation
- Import overwrites existing prompts, does not change active profile
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.sqlite_db import init_prompts_db, seed_default_profiles, _get_db
from app.routers.prompts import router
_VALID_STEPS = {
"decompose", "filter", "generate",
"generate_per_subq", "filter_intro", "filter_section", "filter_outro",
}
_IMPORT_PAYLOAD = {
"format": "legco-reranker-profile/v1",
"profile_name": "A",
"prompts": {
"decompose": "imported decompose",
"filter": "imported filter",
"generate": "imported generate",
"generate_per_subq": "imported generate_per_subq",
"filter_intro": "imported filter_intro",
"filter_section": "imported filter_section",
"filter_outro": "imported filter_outro",
},
}
@pytest.fixture
def client(tmp_path, monkeypatch):
prompts_path = str(tmp_path / "prompts.db")
monkeypatch.setenv("PROMPTS_DB_PATH", prompts_path)
monkeypatch.setenv("HISTORY_DB_PATH", str(tmp_path / "history.db"))
from app.core.config import get_settings
get_settings.cache_clear()
from app.core.dependencies import get_settings_cached
get_settings_cached.cache_clear()
conn = _get_db(prompts_path)
init_prompts_db(conn)
seed_default_profiles(conn)
conn.close()
test_app = FastAPI()
test_app.include_router(router)
yield TestClient(test_app)
get_settings_cached.cache_clear()
get_settings.cache_clear()
def test_import_valid(client):
resp = client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["profile"] == "B"
assert data["imported_steps"] == 7
assert data["source_profile"] == "A"
def test_import_missing_step(client):
payload = {
"format": "legco-reranker-profile/v1",
"profile_name": "A",
"prompts": {k: "x" for k in list(_VALID_STEPS) if k != "decompose"},
}
resp = client.post("/api/v1/prompts/profiles/B/import", json=payload)
assert resp.status_code == 400
assert "decompose" in resp.json()["detail"]
def test_import_unknown_step(client):
payload = {
"format": "legco-reranker-profile/v1",
"profile_name": "A",
"prompts": {**{k: "x" for k in _VALID_STEPS}, "extra_step": "x"},
}
resp = client.post("/api/v1/prompts/profiles/B/import", json=payload)
assert resp.status_code == 400
assert "extra_step" in resp.json()["detail"]
def test_import_invalid_format(client):
payload = {**_IMPORT_PAYLOAD, "format": "v999"}
resp = client.post("/api/v1/prompts/profiles/B/import", json=payload)
assert resp.status_code == 400
assert "v999" in resp.json()["detail"]
def test_import_invalid_target(client):
resp = client.post("/api/v1/prompts/profiles/X/import", json=_IMPORT_PAYLOAD)
assert resp.status_code == 400
def test_import_overwrites_existing(client):
resp = client.get("/api/v1/prompts/profiles/B")
original = resp.json()["prompts"]["decompose"]
client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD)
resp = client.get("/api/v1/prompts/profiles/B")
assert resp.json()["prompts"]["decompose"] == "imported decompose"
assert resp.json()["prompts"]["decompose"] != original
def test_import_does_not_change_active(client):
resp = client.get("/api/v1/prompts/profiles")
active_before = {p["name"]: p["is_active"] for p in resp.json()["profiles"]}
client.post("/api/v1/prompts/profiles/B/import", json=_IMPORT_PAYLOAD)
resp = client.get("/api/v1/prompts/profiles")
active_after = {p["name"]: p["is_active"] for p in resp.json()["profiles"]}
assert active_before == active_after

View File

@ -1,14 +1,15 @@
import React from 'react' import React from 'react'
import { Pencil } from 'lucide-react' import { Pencil, Download } from 'lucide-react'
import type { PromptProfile } from '../types' import type { PromptProfile } from '../types'
export interface ProfileListProps { export interface ProfileListProps {
profiles: PromptProfile[] profiles: PromptProfile[]
selectedProfile: string | null selectedProfile: string | null
onSelect: (name: string) => void onSelect: (name: string) => void
onExport?: (name: string) => void
} }
export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProfile, onSelect }) => { export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProfile, onSelect, onExport }) => {
return ( return (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{profiles.map((profile) => { {profiles.map((profile) => {
@ -43,10 +44,21 @@ export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProf
)} )}
</div> </div>
<div className="flex items-center gap-2">
{onExport && (
<button
type="button"
onClick={() => onExport(profile.name)}
title="Export profile as JSON"
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
<Download className="w-3.5 h-3.5" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => onSelect(profile.name)} onClick={() => onSelect(profile.name)}
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded text-sm font-medium transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded text-sm font-medium transition-all ${
isSelected isSelected
? 'bg-blue-600 text-white hover:bg-blue-700' ? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
@ -56,6 +68,7 @@ export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProf
{isSelected ? 'Editing' : 'Edit'} {isSelected ? 'Editing' : 'Edit'}
</button> </button>
</div> </div>
</div>
) )
})} })}
</div> </div>

View File

@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' import type { QueryRequest, QueryResponse, QueryStreamEvent, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, ProfileExportData, ProfileImportResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types'
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1' const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1'
@ -119,6 +119,16 @@ export const resetPrompts = async (name: string, step?: string): Promise<PromptS
return resp.data return resp.data
} }
export const exportProfile = async (name: string): Promise<ProfileExportData> => {
const resp = await apiClient.get<ProfileExportData>(`/prompts/profiles/${name}/export`)
return resp.data
}
export const importProfile = async (name: string, data: ProfileExportData): Promise<ProfileImportResponse> => {
const resp = await apiClient.post<ProfileImportResponse>(`/prompts/profiles/${name}/import`, data)
return resp.data
}
export const listQueryHistory = async (limit = 50, offset = 0): Promise<QueryHistoryList> => { export const listQueryHistory = async (limit = 50, offset = 0): Promise<QueryHistoryList> => {
const resp = await apiClient.get<QueryHistoryList>(`/history?limit=${limit}&offset=${offset}`) const resp = await apiClient.get<QueryHistoryList>(`/history?limit=${limit}&offset=${offset}`)
return resp.data return resp.data

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api' import { queryDocument, queryDocumentStream, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk, listPromptProfiles, getPromptProfile, activatePromptProfile, updatePrompt, updateAllPrompts, resetPrompts, exportProfile, importProfile, listQueryHistory, getQueryHistoryDetail, deleteQueryHistory, clearQueryHistory, getHistoryStats } from './api'
import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, SubQuestionSources, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types' import type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, SubQuestionSources, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, ProfileExportData, ProfileImportResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types'
import { useState, useCallback, useRef } from 'react' import { useState, useCallback, useRef } from 'react'
export const queryClient = new QueryClient() export const queryClient = new QueryClient()
@ -208,6 +208,16 @@ export const useResetPrompts = () => {
}) })
} }
export const useImportProfile = () => {
const queryClient = useQueryClient()
return useMutation<ProfileImportResponse, Error, { name: string; data: ProfileExportData }>({
mutationFn: ({ name, data }) => importProfile(name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['prompts'] })
},
})
}
export const useQueryHistoryList = (limit = 50, offset = 0) => { export const useQueryHistoryList = (limit = 50, offset = 0) => {
return useQuery<QueryHistoryList, Error>({ return useQuery<QueryHistoryList, Error>({
queryKey: ['history', { limit, offset }], queryKey: ['history', { limit, offset }],

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react' import React, { useState, useRef } from 'react'
import { AlertCircle, RotateCcw } from 'lucide-react' import { AlertCircle, RotateCcw, Upload, Download } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import {
usePromptProfiles, usePromptProfiles,
@ -7,11 +7,13 @@ import {
useActivateProfile, useActivateProfile,
useUpdateAllPrompts, useUpdateAllPrompts,
useResetPrompts, useResetPrompts,
useImportProfile,
} from '../lib/queries' } from '../lib/queries'
import { exportProfile } from '../lib/api'
import { ProfileList } from '../components/ProfileList' import { ProfileList } from '../components/ProfileList'
import { PromptEditor } from '../components/PromptEditor' import { PromptEditor } from '../components/PromptEditor'
import { PlaceholderDocs } from '../components/PlaceholderDocs' import { PlaceholderDocs } from '../components/PlaceholderDocs'
import type { PromptProfile } from '../types' import type { PromptProfile, ProfileExportData } from '../types'
export const SystemPromptsPage: React.FC = () => { export const SystemPromptsPage: React.FC = () => {
const [selectedProfile, setSelectedProfile] = useState<string | null>(null) const [selectedProfile, setSelectedProfile] = useState<string | null>(null)
@ -19,6 +21,13 @@ export const SystemPromptsPage: React.FC = () => {
const [editPrompts, setEditPrompts] = useState<Record<string, string>>({}) const [editPrompts, setEditPrompts] = useState<Record<string, string>>({})
const [hasChanges, setHasChanges] = useState(false) const [hasChanges, setHasChanges] = useState(false)
const [showImportDialog, setShowImportDialog] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importData, setImportData] = useState<ProfileExportData | null>(null)
const [importError, setImportError] = useState<string | null>(null)
const [importTargetProfile, setImportTargetProfile] = useState<string>('')
const fileInputRef = useRef<HTMLInputElement>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { const {
@ -35,6 +44,7 @@ export const SystemPromptsPage: React.FC = () => {
const activateMutation = useActivateProfile() const activateMutation = useActivateProfile()
const updateAllMutation = useUpdateAllPrompts() const updateAllMutation = useUpdateAllPrompts()
const resetMutation = useResetPrompts() const resetMutation = useResetPrompts()
const importMutation = useImportProfile()
const profiles: PromptProfile[] = profilesData?.profiles ?? [] const profiles: PromptProfile[] = profilesData?.profiles ?? []
const activeProfile = profiles.find((p) => p.is_active)?.name ?? '' const activeProfile = profiles.find((p) => p.is_active)?.name ?? ''
@ -121,6 +131,88 @@ export const SystemPromptsPage: React.FC = () => {
setHasChanges(false) setHasChanges(false)
} }
const handleExport = async (name: string) => {
try {
const data = await exportProfile(name)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const date = new Date().toISOString().split('T')[0]
a.href = url
a.download = `legco-profile-${name}-${date}.json`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
console.error('Export failed:', err)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null
setImportFile(file)
setImportData(null)
setImportError(null)
setImportTargetProfile('')
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
try {
const text = String(event.target?.result ?? '')
const parsed = JSON.parse(text) as unknown
if (
typeof parsed === 'object' &&
parsed !== null &&
'format' in parsed &&
'profile_name' in parsed &&
'exported_at' in parsed &&
'prompts' in parsed &&
typeof (parsed as Record<string, unknown>).prompts === 'object'
) {
const data = parsed as ProfileExportData
setImportData(data)
const profileNames = profiles.map((p) => p.name)
if (profileNames.includes(data.profile_name)) {
setImportTargetProfile(data.profile_name)
} else if (profileNames.length > 0) {
setImportTargetProfile(profileNames[0])
}
} else {
setImportError('Invalid profile export file format.')
}
} catch {
setImportError('Failed to parse JSON file.')
}
}
reader.readAsText(file)
}
const handleImport = () => {
if (!importTargetProfile || !importData) return
importMutation.mutate(
{ name: importTargetProfile, data: importData },
{
onSuccess: () => {
setShowImportDialog(false)
setImportFile(null)
setImportData(null)
setImportError(null)
setImportTargetProfile('')
},
}
)
}
const handleCloseImportDialog = () => {
setShowImportDialog(false)
setImportFile(null)
setImportData(null)
setImportError(null)
setImportTargetProfile('')
}
const isSaving = updateAllMutation.isPending || resetMutation.isPending || activateMutation.isPending const isSaving = updateAllMutation.isPending || resetMutation.isPending || activateMutation.isPending
if (isLoadingProfiles) { if (isLoadingProfiles) {
@ -195,12 +287,27 @@ export const SystemPromptsPage: React.FC = () => {
> >
{activateMutation.isPending ? 'Setting...' : 'Set Active'} {activateMutation.isPending ? 'Setting...' : 'Set Active'}
</button> </button>
<button
type="button"
onClick={() => {
setShowImportDialog(true)
setImportFile(null)
setImportData(null)
setImportError(null)
setImportTargetProfile('')
}}
className="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all duration-200"
>
<Upload className="w-3.5 h-3.5 inline mr-1" />
Import
</button>
</div> </div>
<ProfileList <ProfileList
profiles={profiles} profiles={profiles}
selectedProfile={selectedProfile} selectedProfile={selectedProfile}
onSelect={handleSelectProfile} onSelect={handleSelectProfile}
onExport={handleExport}
/> />
{selectedProfile && ( {selectedProfile && (
@ -236,6 +343,102 @@ export const SystemPromptsPage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{showImportDialog && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) handleCloseImportDialog()
}}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Import Profile</h2>
{importError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 rounded p-3 flex items-start gap-2">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
<span className="text-sm">{importError}</span>
</div>
)}
{!importData ? (
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-600">Click to select a JSON file</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileSelect}
/>
</div>
) : (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Source Profile:</span>
<span className="font-medium text-gray-900">{importData.profile_name}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Exported At:</span>
<span className="font-medium text-gray-900">{importData.exported_at}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Steps:</span>
<span className="font-medium text-gray-900">
{Object.keys(importData.prompts).length}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Target Profile
</label>
<select
value={importTargetProfile}
onChange={(e) => setImportTargetProfile(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
>
<option value="">Select profile...</option>
{profiles.map((p) => (
<option key={p.name} value={p.name}>
Profile {p.name}
</option>
))}
</select>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-800">
This will overwrite all prompts for Profile {importTargetProfile || '...'}.
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleImport}
disabled={!importTargetProfile || importMutation.isPending}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
>
{importMutation.isPending ? 'Importing...' : 'Import'}
</button>
<button
type="button"
onClick={handleCloseImportDialog}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all duration-200"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -99,6 +99,20 @@ export interface PromptStatusResponse {
reset_step?: string reset_step?: string
} }
export interface ProfileExportData {
format: string
profile_name: string
exported_at: string
prompts: Record<string, string>
}
export interface ProfileImportResponse {
status: string
profile: string
imported_steps: number
source_profile: string
}
export interface QueryHistorySummary { export interface QueryHistorySummary {
id: number id: number
input_text: string input_text: string