feat(prompts): add JSON export/import for profile prompt configurations
This commit is contained in:
parent
bb6b159315
commit
23796d6a0c
|
|
@ -27,3 +27,41 @@ class PromptBatchUpdateRequest(BaseModel):
|
|||
|
||||
class ResetToDefaultsRequest(BaseModel):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.dependencies import get_prompt_service
|
||||
from app.models.prompts import (
|
||||
|
|
@ -10,6 +12,10 @@ from app.models.prompts import (
|
|||
PromptUpdateRequest,
|
||||
PromptBatchUpdateRequest,
|
||||
ResetToDefaultsRequest,
|
||||
ProfileExportResponse,
|
||||
AllProfilesExportResponse,
|
||||
ProfileImportRequest,
|
||||
ProfileImportResponse,
|
||||
)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
_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)
|
||||
def list_profiles():
|
||||
svc = get_prompt_service()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import React from 'react'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { Pencil, Download } from 'lucide-react'
|
||||
import type { PromptProfile } from '../types'
|
||||
|
||||
export interface ProfileListProps {
|
||||
profiles: PromptProfile[]
|
||||
selectedProfile: string | null
|
||||
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 (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{profiles.map((profile) => {
|
||||
|
|
@ -43,10 +44,21 @@ export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProf
|
|||
)}
|
||||
</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
|
||||
type="button"
|
||||
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
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: '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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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'
|
||||
|
||||
|
|
@ -119,6 +119,16 @@ export const resetPrompts = async (name: string, step?: string): Promise<PromptS
|
|||
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> => {
|
||||
const resp = await apiClient.get<QueryHistoryList>(`/history?limit=${limit}&offset=${offset}`)
|
||||
return resp.data
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
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 type { QueryRequest, QueryResponse, QueryStreamEvent, SourceMetadata, SubQuestionSources, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse, PromptProfileListResponse, PromptSetResponse, PromptUpdateRequest, PromptBatchUpdateRequest, PromptActivateResponse, PromptStatusResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types'
|
||||
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, ProfileExportData, ProfileImportResponse, QueryHistoryList, QueryHistoryDetail, HistoryStats, HistoryDeleteResponse } from '../types'
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
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) => {
|
||||
return useQuery<QueryHistoryList, Error>({
|
||||
queryKey: ['history', { limit, offset }],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react'
|
||||
import { AlertCircle, RotateCcw } from 'lucide-react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { AlertCircle, RotateCcw, Upload, Download } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
usePromptProfiles,
|
||||
|
|
@ -7,11 +7,13 @@ import {
|
|||
useActivateProfile,
|
||||
useUpdateAllPrompts,
|
||||
useResetPrompts,
|
||||
useImportProfile,
|
||||
} from '../lib/queries'
|
||||
import { exportProfile } from '../lib/api'
|
||||
import { ProfileList } from '../components/ProfileList'
|
||||
import { PromptEditor } from '../components/PromptEditor'
|
||||
import { PlaceholderDocs } from '../components/PlaceholderDocs'
|
||||
import type { PromptProfile } from '../types'
|
||||
import type { PromptProfile, ProfileExportData } from '../types'
|
||||
|
||||
export const SystemPromptsPage: React.FC = () => {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null)
|
||||
|
|
@ -19,6 +21,13 @@ export const SystemPromptsPage: React.FC = () => {
|
|||
const [editPrompts, setEditPrompts] = useState<Record<string, string>>({})
|
||||
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 {
|
||||
|
|
@ -35,6 +44,7 @@ export const SystemPromptsPage: React.FC = () => {
|
|||
const activateMutation = useActivateProfile()
|
||||
const updateAllMutation = useUpdateAllPrompts()
|
||||
const resetMutation = useResetPrompts()
|
||||
const importMutation = useImportProfile()
|
||||
|
||||
const profiles: PromptProfile[] = profilesData?.profiles ?? []
|
||||
const activeProfile = profiles.find((p) => p.is_active)?.name ?? ''
|
||||
|
|
@ -121,6 +131,88 @@ export const SystemPromptsPage: React.FC = () => {
|
|||
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
|
||||
|
||||
if (isLoadingProfiles) {
|
||||
|
|
@ -195,12 +287,27 @@ export const SystemPromptsPage: React.FC = () => {
|
|||
>
|
||||
{activateMutation.isPending ? 'Setting...' : 'Set Active'}
|
||||
</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>
|
||||
|
||||
<ProfileList
|
||||
profiles={profiles}
|
||||
selectedProfile={selectedProfile}
|
||||
onSelect={handleSelectProfile}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
{selectedProfile && (
|
||||
|
|
@ -236,6 +343,102 @@ export const SystemPromptsPage: React.FC = () => {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,20 @@ export interface PromptStatusResponse {
|
|||
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 {
|
||||
id: number
|
||||
input_text: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue