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):
|
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 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,18 +44,30 @@ export const ProfileList: React.FC<ProfileListProps> = ({ profiles, selectedProf
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
{onExport && (
|
||||||
onClick={() => onSelect(profile.name)}
|
<button
|
||||||
className={`w-full flex items-center justify-center gap-2 px-3 py-2 rounded text-sm font-medium transition-all ${
|
type="button"
|
||||||
isSelected
|
onClick={() => onExport(profile.name)}
|
||||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
title="Export profile as JSON"
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
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" />
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
</button>
|
||||||
{isSelected ? 'Editing' : 'Edit'}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(profile.name)}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{isSelected ? 'Editing' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue