Compare commits
2 commits
a3b25d5e46
...
81b1cacc5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b1cacc5c | |||
| 6e7f715ef2 |
8 changed files with 937 additions and 31 deletions
|
|
@ -1,12 +1,8 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import ValidationError
|
||||
|
|
@ -378,37 +374,17 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
|||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_product_parse_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
response_schema=ProductParseResponse,
|
||||
max_output_tokens=16384,
|
||||
temperature=0.0,
|
||||
),
|
||||
)
|
||||
candidate = response.candidates[0] if response.candidates else None
|
||||
finish_reason = str(candidate.finish_reason) if candidate else "unknown"
|
||||
raw = response.text
|
||||
if not raw:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"LLM returned an empty response (finish_reason={finish_reason})",
|
||||
)
|
||||
# Fallback: extract JSON object in case the model adds preamble or markdown fences
|
||||
if not raw.lstrip().startswith("{"):
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start != -1 and end != -1:
|
||||
raw = raw[start : end + 1]
|
||||
# Replace JS-style non-JSON literals that some models emit
|
||||
raw = re.sub(r":\s*NaN\b", ": null", raw)
|
||||
raw = re.sub(r":\s*Infinity\b", ": null", raw)
|
||||
raw = re.sub(r":\s*undefined\b", ": null", raw)
|
||||
raise HTTPException(status_code=502, detail="LLM returned an empty response")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(
|
||||
"Gemini parse-text JSON error at pos %d finish_reason=%s context=%r",
|
||||
e.pos,
|
||||
finish_reason,
|
||||
raw[max(0, e.pos - 80) : e.pos + 80],
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
try:
|
||||
return ProductParseResponse.model_validate(parsed)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
from datetime import date
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import BaseModel as PydanticBase
|
||||
from sqlmodel import Session, SQLModel, col, select
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.models import GroomingSchedule, Routine, RoutineStep
|
||||
from innercontext.llm import get_gemini_client
|
||||
from innercontext.models import GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot
|
||||
from innercontext.models.enums import GroomingAction, PartOfDay
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -60,6 +64,188 @@ class GroomingScheduleUpdate(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SuggestedStep(SQLModel):
|
||||
product_id: Optional[UUID] = None
|
||||
action_type: Optional[GroomingAction] = None
|
||||
action_notes: Optional[str] = None
|
||||
dose: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
|
||||
|
||||
class SuggestRoutineRequest(SQLModel):
|
||||
routine_date: date
|
||||
part_of_day: PartOfDay
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class RoutineSuggestion(SQLModel):
|
||||
steps: list[SuggestedStep]
|
||||
reasoning: str
|
||||
|
||||
|
||||
class SuggestBatchRequest(SQLModel):
|
||||
from_date: date
|
||||
to_date: date
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class DayPlan(SQLModel):
|
||||
date: date
|
||||
am_steps: list[SuggestedStep]
|
||||
pm_steps: list[SuggestedStep]
|
||||
reasoning: str
|
||||
|
||||
|
||||
class BatchSuggestion(SQLModel):
|
||||
days: list[DayPlan]
|
||||
overall_reasoning: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas for Gemini structured output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StepOut(PydanticBase):
|
||||
product_id: Optional[str] = None
|
||||
action_type: Optional[GroomingAction] = None
|
||||
dose: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
action_notes: Optional[str] = None
|
||||
|
||||
|
||||
class _SuggestionOut(PydanticBase):
|
||||
steps: list[_StepOut]
|
||||
reasoning: str
|
||||
|
||||
|
||||
class _DayPlanOut(PydanticBase):
|
||||
date: str
|
||||
am_steps: list[_StepOut]
|
||||
pm_steps: list[_StepOut]
|
||||
reasoning: str
|
||||
|
||||
|
||||
class _BatchOut(PydanticBase):
|
||||
days: list[_DayPlanOut]
|
||||
overall_reasoning: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DAY_NAMES = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"]
|
||||
|
||||
|
||||
def _ev(v: object) -> str:
|
||||
return v.value if v is not None and hasattr(v, "value") else str(v) if v is not None else ""
|
||||
|
||||
|
||||
def _build_skin_context(session: Session) -> str:
|
||||
snapshot = session.exec(
|
||||
select(SkinConditionSnapshot).order_by(col(SkinConditionSnapshot.snapshot_date).desc())
|
||||
).first()
|
||||
if snapshot is None:
|
||||
return "STAN SKÓRY: brak danych\n"
|
||||
ev = _ev
|
||||
return (
|
||||
f"STAN SKÓRY (snapshot z {snapshot.snapshot_date}):\n"
|
||||
f" Ogólny stan: {ev(snapshot.overall_state)}\n"
|
||||
f" Nawilżenie: {snapshot.hydration_level}/5\n"
|
||||
f" Bariera: {ev(snapshot.barrier_state)}\n"
|
||||
f" Aktywne problemy: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n"
|
||||
f" Priorytety: {', '.join(snapshot.priorities or [])}\n"
|
||||
f" Uwagi: {snapshot.notes or 'brak'}\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_grooming_context(session: Session, weekdays: Optional[list[int]] = None) -> str:
|
||||
entries = session.exec(select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)).all()
|
||||
if not entries:
|
||||
return "HARMONOGRAM PIELĘGNACJI: brak\n"
|
||||
lines = ["HARMONOGRAM PIELĘGNACJI:"]
|
||||
for e in entries:
|
||||
if weekdays is not None and e.day_of_week not in weekdays:
|
||||
continue
|
||||
day_name = _DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week)
|
||||
lines.append(f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else ""))
|
||||
if len(lines) == 1:
|
||||
lines.append(" (brak wpisów dla podanych dni)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _build_recent_history(session: Session) -> str:
|
||||
cutoff = date.today() - timedelta(days=7)
|
||||
routines = session.exec(
|
||||
select(Routine)
|
||||
.where(Routine.routine_date >= cutoff)
|
||||
.order_by(col(Routine.routine_date).desc())
|
||||
).all()
|
||||
if not routines:
|
||||
return "OSTATNIE RUTYNY (7 dni): brak\n"
|
||||
lines = ["OSTATNIE RUTYNY (7 dni):"]
|
||||
for r in routines:
|
||||
steps = session.exec(
|
||||
select(RoutineStep)
|
||||
.where(RoutineStep.routine_id == r.id)
|
||||
.order_by(RoutineStep.order_index)
|
||||
).all()
|
||||
step_names = []
|
||||
for s in steps:
|
||||
if s.product_id:
|
||||
p = session.get(Product, s.product_id)
|
||||
step_names.append(p.name if p else str(s.product_id))
|
||||
elif s.action_type:
|
||||
step_names.append(_ev(s.action_type))
|
||||
lines.append(f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
|
||||
stmt = select(Product).where(Product.is_medication == False).where(Product.is_tool == False) # noqa: E712
|
||||
products = session.exec(stmt).all()
|
||||
lines = ["DOSTĘPNE PRODUKTY:"]
|
||||
for p in products:
|
||||
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
|
||||
continue
|
||||
ctx = p.to_llm_context()
|
||||
entry = (
|
||||
f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
|
||||
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
|
||||
f" targets={ctx.get('targets', [])}"
|
||||
)
|
||||
profile = ctx.get("product_effect_profile", {})
|
||||
if profile:
|
||||
notable = {k: v for k, v in profile.items() if v and v > 0}
|
||||
if notable:
|
||||
entry += f" effects={notable}"
|
||||
if ctx.get("incompatible_with"):
|
||||
entry += f" incompatible_with={ctx['incompatible_with']}"
|
||||
if ctx.get("context_rules"):
|
||||
entry += f" context_rules={ctx['context_rules']}"
|
||||
if ctx.get("min_interval_hours"):
|
||||
entry += f" min_interval_hours={ctx['min_interval_hours']}"
|
||||
if ctx.get("max_frequency_per_week"):
|
||||
entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}"
|
||||
lines.append(entry)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
_RULES = """\
|
||||
ZASADY:
|
||||
- Kolejność warstw: cleanser → toner → essence → serum → moisturizer → [SPF dla AM]
|
||||
- Respektuj incompatible_with (scope: same_step / same_day / same_period)
|
||||
- Respektuj context_rules (safe_after_shaving, safe_after_acids itp.)
|
||||
- Respektuj min_interval_hours i max_frequency_per_week
|
||||
- 4–7 kroków na rutynę
|
||||
- product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych
|
||||
- action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null)
|
||||
- Nie używaj retinoidów i kwasów w tej samej rutynie
|
||||
- W AM zawsze uwzględnij SPF jeśli dostępny
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -96,6 +282,164 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session))
|
|||
return routine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI suggestion endpoints (must appear BEFORE /{routine_id})
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/suggest", response_model=RoutineSuggestion)
|
||||
def suggest_routine(
|
||||
data: SuggestRoutineRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
client, model = get_gemini_client()
|
||||
|
||||
weekday = data.routine_date.weekday()
|
||||
skin_ctx = _build_skin_context(session)
|
||||
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
|
||||
history_ctx = _build_recent_history(session)
|
||||
products_ctx = _build_products_context(session, time_filter=data.part_of_day.value)
|
||||
|
||||
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
|
||||
day_name = _DAY_NAMES[weekday]
|
||||
|
||||
prompt = (
|
||||
f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} "
|
||||
f"na {data.routine_date} ({day_name}).\n\n"
|
||||
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
|
||||
"\nZwróć JSON zgodny ze schematem."
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=genai_types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=_SuggestionOut,
|
||||
max_output_tokens=4096,
|
||||
temperature=0.4,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
|
||||
|
||||
raw = response.text
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="LLM returned an empty response")
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
|
||||
steps = [
|
||||
SuggestedStep(
|
||||
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
|
||||
action_type=s.get("action_type") or None,
|
||||
action_notes=s.get("action_notes"),
|
||||
dose=s.get("dose"),
|
||||
region=s.get("region"),
|
||||
)
|
||||
for s in parsed.get("steps", [])
|
||||
]
|
||||
return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", ""))
|
||||
|
||||
|
||||
@router.post("/suggest-batch", response_model=BatchSuggestion)
|
||||
def suggest_batch(
|
||||
data: SuggestBatchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
delta = (data.to_date - data.from_date).days + 1
|
||||
if delta > 14:
|
||||
raise HTTPException(status_code=400, detail="Date range must not exceed 14 days.")
|
||||
if data.from_date > data.to_date:
|
||||
raise HTTPException(status_code=400, detail="from_date must be <= to_date.")
|
||||
|
||||
client, model = get_gemini_client()
|
||||
|
||||
weekdays = list({(data.from_date + timedelta(days=i)).weekday() for i in range(delta)})
|
||||
skin_ctx = _build_skin_context(session)
|
||||
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
|
||||
history_ctx = _build_recent_history(session)
|
||||
products_ctx = _build_products_context(session)
|
||||
|
||||
date_range_lines = []
|
||||
for i in range(delta):
|
||||
d = data.from_date + timedelta(days=i)
|
||||
date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})")
|
||||
dates_str = "\n".join(date_range_lines)
|
||||
|
||||
notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
|
||||
|
||||
prompt = (
|
||||
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n"
|
||||
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
|
||||
"\nDodatkowe zasady dla planu wielodniowego:\n"
|
||||
" - Retinol/retinoidy: przestrzegaj max_frequency_per_week i min_interval_hours między użyciami\n"
|
||||
" - Nie stosuj kwasów i retinoidów tego samego dnia\n"
|
||||
" - Uwzględnij safe_after_shaving dla dni golenia\n"
|
||||
" - Zmienność aktywnych składników przez dni dla lepszej tolerancji\n"
|
||||
" - Pole date w każdym dniu MUSI być w formacie YYYY-MM-DD\n"
|
||||
"\nZwróć JSON zgodny ze schematem."
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=genai_types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=_BatchOut,
|
||||
max_output_tokens=8192,
|
||||
temperature=0.4,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
|
||||
|
||||
raw = response.text
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="LLM returned an empty response")
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
|
||||
def _parse_steps(raw_steps: list) -> list[SuggestedStep]:
|
||||
result = []
|
||||
for s in raw_steps:
|
||||
result.append(
|
||||
SuggestedStep(
|
||||
product_id=UUID(s["product_id"]) if s.get("product_id") else None,
|
||||
action_type=s.get("action_type") or None,
|
||||
action_notes=s.get("action_notes"),
|
||||
dose=s.get("dose"),
|
||||
region=s.get("region"),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
days = []
|
||||
for day_raw in parsed.get("days", []):
|
||||
try:
|
||||
day_date = date.fromisoformat(day_raw["date"])
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
days.append(
|
||||
DayPlan(
|
||||
date=day_date,
|
||||
am_steps=_parse_steps(day_raw.get("am_steps", [])),
|
||||
pm_steps=_parse_steps(day_raw.get("pm_steps", [])),
|
||||
reasoning=day_raw.get("reasoning", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return BatchSuggestion(days=days, overall_reasoning=parsed.get("overall_reasoning", ""))
|
||||
|
||||
|
||||
# Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed
|
||||
@router.get("/grooming-schedule", response_model=list[GroomingSchedule])
|
||||
def list_grooming_schedule(session: Session = Depends(get_session)):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from uuid import UUID, uuid4
|
|||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import BaseModel as PydanticBase
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
|
||||
|
|
@ -70,6 +71,21 @@ class SkinPhotoAnalysisResponse(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class _SkinAnalysisOut(PydanticBase):
|
||||
overall_state: Optional[OverallSkinState] = None
|
||||
skin_type: Optional[SkinType] = None
|
||||
texture: Optional[SkinTexture] = None
|
||||
hydration_level: Optional[int] = None
|
||||
sebum_tzone: Optional[int] = None
|
||||
sebum_cheeks: Optional[int] = None
|
||||
sensitivity_level: Optional[int] = None
|
||||
barrier_state: Optional[BarrierState] = None
|
||||
active_concerns: Optional[list[SkinConcern]] = None
|
||||
risks: Optional[list[str]] = None
|
||||
priorities: Optional[list[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -148,6 +164,7 @@ async def analyze_skin_photos(
|
|||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_skin_photo_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
response_schema=_SkinAnalysisOut,
|
||||
max_output_tokens=2048,
|
||||
temperature=0.0,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||
import type {
|
||||
ActiveIngredient,
|
||||
BatchSuggestion,
|
||||
GroomingSchedule,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
PartOfDay,
|
||||
Product,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineSuggestion,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot
|
||||
} from './types';
|
||||
|
|
@ -130,6 +133,18 @@ export const updateRoutineStep = (stepId: string, body: Record<string, unknown>)
|
|||
export const deleteRoutineStep = (stepId: string): Promise<void> =>
|
||||
api.del(`/routines/steps/${stepId}`);
|
||||
|
||||
export const suggestRoutine = (body: {
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
|
||||
|
||||
export const suggestBatch = (body: {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
notes?: string;
|
||||
}): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body);
|
||||
|
||||
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
|
||||
api.get('/routines/grooming-schedule');
|
||||
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
|
||||
|
|
|
|||
|
|
@ -191,6 +191,31 @@ export interface GroomingSchedule {
|
|||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SuggestedStep {
|
||||
product_id?: string;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface RoutineSuggestion {
|
||||
steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface DayPlan {
|
||||
date: string;
|
||||
am_steps: SuggestedStep[];
|
||||
pm_steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface BatchSuggestion {
|
||||
days: DayPlan[];
|
||||
overall_reasoning: string;
|
||||
}
|
||||
|
||||
// ─── Health types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MedicationUsage {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@
|
|||
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
|
||||
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
|
||||
</div>
|
||||
<Button href="/routines/new">+ New routine</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button href="/routines/suggest" variant="outline">Zaproponuj rutynę AI</Button>
|
||||
<Button href="/routines/new">+ New routine</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sortedDates.length}
|
||||
|
|
|
|||
152
frontend/src/routes/routines/suggest/+page.server.ts
Normal file
152
frontend/src/routes/routines/suggest/+page.server.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const products = await getProducts();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return { products, today };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
suggest: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const routine_date = form.get('routine_date') as string;
|
||||
const part_of_day = form.get('part_of_day') as 'am' | 'pm';
|
||||
const notes = (form.get('notes') as string) || undefined;
|
||||
|
||||
if (!routine_date || !part_of_day) {
|
||||
return fail(400, { error: 'Data i pora dnia są wymagane.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const suggestion = await suggestRoutine({ routine_date, part_of_day, notes });
|
||||
return { suggestion, routine_date, part_of_day };
|
||||
} catch (e) {
|
||||
return fail(502, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
suggestBatch: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const from_date = form.get('from_date') as string;
|
||||
const to_date = form.get('to_date') as string;
|
||||
const notes = (form.get('notes') as string) || undefined;
|
||||
|
||||
if (!from_date || !to_date) {
|
||||
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
|
||||
}
|
||||
|
||||
const delta =
|
||||
(new Date(to_date).getTime() - new Date(from_date).getTime()) / (1000 * 60 * 60 * 24) + 1;
|
||||
if (delta > 14) {
|
||||
return fail(400, { error: 'Zakres dat nie może przekraczać 14 dni.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await suggestBatch({ from_date, to_date, notes });
|
||||
return { batch, from_date, to_date };
|
||||
} catch (e) {
|
||||
return fail(502, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
save: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const routine_date = form.get('routine_date') as string;
|
||||
const part_of_day = form.get('part_of_day') as string;
|
||||
const steps_json = form.get('steps') as string;
|
||||
|
||||
if (!routine_date || !part_of_day || !steps_json) {
|
||||
return fail(400, { error: 'Brakujące dane do zapisania.' });
|
||||
}
|
||||
|
||||
let steps: Array<{
|
||||
product_id?: string;
|
||||
action_type?: string;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
}>;
|
||||
try {
|
||||
steps = JSON.parse(steps_json);
|
||||
} catch {
|
||||
return fail(400, { error: 'Nieprawidłowy format kroków.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const routine = await createRoutine({ routine_date, part_of_day });
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
await addRoutineStep(routine.id, {
|
||||
order_index: i + 1,
|
||||
product_id: s.product_id || undefined,
|
||||
action_type: s.action_type || undefined,
|
||||
action_notes: s.action_notes || undefined,
|
||||
dose: s.dose || undefined,
|
||||
region: s.region || undefined
|
||||
});
|
||||
}
|
||||
redirect(303, `/routines/${routine.id}`);
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
saveBatch: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const days_json = form.get('days') as string;
|
||||
|
||||
if (!days_json) {
|
||||
return fail(400, { error: 'Brakujące dane do zapisania.' });
|
||||
}
|
||||
|
||||
let days: Array<{
|
||||
date: string;
|
||||
am_steps: Array<{
|
||||
product_id?: string;
|
||||
action_type?: string;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
}>;
|
||||
pm_steps: Array<{
|
||||
product_id?: string;
|
||||
action_type?: string;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
}>;
|
||||
}>;
|
||||
try {
|
||||
days = JSON.parse(days_json);
|
||||
} catch {
|
||||
return fail(400, { error: 'Nieprawidłowy format danych.' });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const day of days) {
|
||||
for (const part_of_day of ['am', 'pm'] as const) {
|
||||
const steps = part_of_day === 'am' ? day.am_steps : day.pm_steps;
|
||||
if (steps.length === 0) continue;
|
||||
const routine = await createRoutine({ routine_date: day.date, part_of_day });
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
await addRoutineStep(routine.id, {
|
||||
order_index: i + 1,
|
||||
product_id: s.product_id || undefined,
|
||||
action_type: s.action_type || undefined,
|
||||
action_notes: s.action_notes || undefined,
|
||||
dose: s.dose || undefined,
|
||||
region: s.region || undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
|
||||
redirect(303, '/routines');
|
||||
}
|
||||
};
|
||||
374
frontend/src/routes/routines/suggest/+page.svelte
Normal file
374
frontend/src/routes/routines/suggest/+page.svelte
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
|
||||
|
||||
// Single suggestion state
|
||||
let suggestion = $state<RoutineSuggestion | null>(null);
|
||||
let suggestionDate = $state('');
|
||||
let suggestionPod = $state('');
|
||||
let partOfDay = $state<'am' | 'pm'>('am');
|
||||
|
||||
// Batch suggestion state
|
||||
let batch = $state<BatchSuggestion | null>(null);
|
||||
let expandedDays = new SvelteSet<string>();
|
||||
|
||||
// Error + loading
|
||||
let errorMsg = $state<string | null>(null);
|
||||
let loadingSingle = $state(false);
|
||||
let loadingBatch = $state(false);
|
||||
let loadingSave = $state(false);
|
||||
|
||||
function stepLabel(step: SuggestedStep): string {
|
||||
if (step.product_id && productMap[step.product_id]) {
|
||||
const p = productMap[step.product_id];
|
||||
return `${p.brand} ${p.name}`;
|
||||
}
|
||||
if (step.action_type) return step.action_type.replace(/_/g, ' ');
|
||||
return step.action_notes ?? 'Unknown step';
|
||||
}
|
||||
|
||||
function stepMeta(step: SuggestedStep): string {
|
||||
const parts: string[] = [];
|
||||
if (step.dose) parts.push(step.dose);
|
||||
if (step.region) parts.push(step.region);
|
||||
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function toggleDay(d: string) {
|
||||
if (expandedDays.has(d)) expandedDays.delete(d);
|
||||
else expandedDays.add(d);
|
||||
}
|
||||
|
||||
function enhanceSingle() {
|
||||
loadingSingle = true;
|
||||
errorMsg = null;
|
||||
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
||||
loadingSingle = false;
|
||||
if (result.type === 'success' && result.data?.suggestion) {
|
||||
suggestion = result.data.suggestion as RoutineSuggestion;
|
||||
suggestionDate = result.data.routine_date as string;
|
||||
suggestionPod = result.data.part_of_day as string;
|
||||
errorMsg = null;
|
||||
} else if (result.type === 'failure') {
|
||||
errorMsg = (result.data?.error as string) ?? 'Błąd podczas generowania.';
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}
|
||||
|
||||
function enhanceBatch() {
|
||||
loadingBatch = true;
|
||||
errorMsg = null;
|
||||
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
||||
loadingBatch = false;
|
||||
if (result.type === 'success' && result.data?.batch) {
|
||||
batch = result.data.batch as BatchSuggestion;
|
||||
expandedDays.clear();
|
||||
for (const d of batch.days) expandedDays.add(d.date);
|
||||
errorMsg = null;
|
||||
} else if (result.type === 'failure') {
|
||||
errorMsg = (result.data?.error as string) ?? 'Błąd podczas generowania planu.';
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}
|
||||
|
||||
function enhanceSave() {
|
||||
loadingSave = true;
|
||||
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
|
||||
loadingSave = false;
|
||||
if (result.type === 'failure') {
|
||||
errorMsg = (result.data?.error as string) ?? 'Błąd podczas zapisywania.';
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Zaproponuj rutynę AI — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Rutyny</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">Propozycja rutyny AI</h2>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
<Tabs value="single">
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>Jedna rutyna</TabsTrigger>
|
||||
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>Batch / Urlop</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
||||
<TabsContent value="single" class="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle class="text-base">Parametry</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="single_date">Data</Label>
|
||||
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pora dnia</Label>
|
||||
<input type="hidden" name="part_of_day" value={partOfDay} />
|
||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
|
||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="am">AM (rano)</SelectItem>
|
||||
<SelectItem value="pm">PM (wieczór)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="single_notes">Dodatkowy kontekst dla AI <span class="text-muted-foreground text-xs">(opcjonalny)</span></Label>
|
||||
<textarea
|
||||
id="single_notes"
|
||||
name="notes"
|
||||
rows="2"
|
||||
placeholder="np. wieczór imprezowy, skupiam się na nawilżeniu..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loadingSingle} class="w-full">
|
||||
{#if loadingSingle}
|
||||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
Generuję…
|
||||
{:else}
|
||||
Generuj propozycję
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if suggestion}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold">Propozycja</h3>
|
||||
<Badge variant={suggestionPod === 'am' ? 'default' : 'secondary'}>
|
||||
{suggestionPod.toUpperCase()}
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground">{suggestionDate}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reasoning -->
|
||||
<Card class="border-muted bg-muted/30">
|
||||
<CardContent class="pt-4">
|
||||
<p class="text-sm text-muted-foreground italic">{suggestion.reasoning}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-2">
|
||||
{#each suggestion.steps as step, i (i)}
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{stepLabel(step)}</p>
|
||||
{#if stepMeta(step)}
|
||||
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Save form -->
|
||||
<form method="POST" action="?/save" use:enhance={enhanceSave} class="flex gap-3">
|
||||
<input type="hidden" name="routine_date" value={suggestionDate} />
|
||||
<input type="hidden" name="part_of_day" value={suggestionPod} />
|
||||
<input type="hidden" name="steps" value={JSON.stringify(suggestion.steps)} />
|
||||
<Button type="submit" disabled={loadingSave}>
|
||||
{#if loadingSave}
|
||||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
Zapisuję…
|
||||
{:else}
|
||||
Zapisz rutynę
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" type="submit" form="suggest-single-form" disabled={loadingSingle}>
|
||||
Wygeneruj ponownie
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
|
||||
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
|
||||
<TabsContent value="batch" class="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle class="text-base">Zakres dat</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="from_date">Od</Label>
|
||||
<Input id="from_date" name="from_date" type="date" value={data.today} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="to_date">Do (max 14 dni)</Label>
|
||||
<Input id="to_date" name="to_date" type="date" value={data.today} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="batch_notes">Kontekst / cel wyjazdu <span class="text-muted-foreground text-xs">(opcjonalny)</span></Label>
|
||||
<textarea
|
||||
id="batch_notes"
|
||||
name="notes"
|
||||
rows="2"
|
||||
placeholder="np. słoneczna podróż do Włoch, aktywny urlop górski..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loadingBatch} class="w-full">
|
||||
{#if loadingBatch}
|
||||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
Generuję plan…
|
||||
{:else}
|
||||
Generuj plan
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if batch}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">Plan ({batch.days.length} dni)</h3>
|
||||
|
||||
<!-- Overall reasoning -->
|
||||
{#if batch.overall_reasoning}
|
||||
<Card class="border-muted bg-muted/30">
|
||||
<CardContent class="pt-4">
|
||||
<p class="text-sm text-muted-foreground italic">{batch.overall_reasoning}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Day cards -->
|
||||
<div class="space-y-3">
|
||||
{#each batch.days as day (day.date)}
|
||||
{@const isOpen = expandedDays.has(day.date)}
|
||||
<div class="rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors"
|
||||
onclick={() => toggleDay(day.date)}
|
||||
>
|
||||
<span class="font-medium text-sm">{day.date}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
AM {day.am_steps.length} kroków · PM {day.pm_steps.length} kroków
|
||||
</span>
|
||||
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="border-t border-border px-4 py-3 space-y-4">
|
||||
{#if day.reasoning}
|
||||
<p class="text-xs text-muted-foreground italic">{day.reasoning}</p>
|
||||
{/if}
|
||||
|
||||
<!-- AM steps -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge>AM</Badge>
|
||||
<span class="text-xs text-muted-foreground">{day.am_steps.length} kroków</span>
|
||||
</div>
|
||||
{#if day.am_steps.length}
|
||||
<div class="space-y-1">
|
||||
{#each day.am_steps as step, i (i)}
|
||||
<div class="flex items-start gap-2 rounded-sm bg-muted/30 px-3 py-2">
|
||||
<span class="text-xs text-muted-foreground w-4 shrink-0 mt-0.5">{i + 1}.</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm">{stepLabel(step)}</p>
|
||||
{#if stepMeta(step)}
|
||||
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Brak kroków AM.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- PM steps -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="secondary">PM</Badge>
|
||||
<span class="text-xs text-muted-foreground">{day.pm_steps.length} kroków</span>
|
||||
</div>
|
||||
{#if day.pm_steps.length}
|
||||
<div class="space-y-1">
|
||||
{#each day.pm_steps as step, i (i)}
|
||||
<div class="flex items-start gap-2 rounded-sm bg-muted/30 px-3 py-2">
|
||||
<span class="text-xs text-muted-foreground w-4 shrink-0 mt-0.5">{i + 1}.</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm">{stepLabel(step)}</p>
|
||||
{#if stepMeta(step)}
|
||||
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Brak kroków PM.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Save all form -->
|
||||
<form method="POST" action="?/saveBatch" use:enhance={enhanceSave} class="flex gap-3">
|
||||
<input type="hidden" name="days" value={JSON.stringify(batch.days)} />
|
||||
<Button type="submit" disabled={loadingSave}>
|
||||
{#if loadingSave}
|
||||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
Zapisuję…
|
||||
{:else}
|
||||
Zapisz wszystkie rutyny
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" type="submit" form="batch-form" disabled={loadingBatch}>
|
||||
Wygeneruj ponownie
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue