Compare commits
1 commit
54903a3bed
...
a2500a919b
| Author | SHA1 | Date | |
|---|---|---|---|
| a2500a919b |
4 changed files with 49 additions and 342 deletions
|
|
@ -356,21 +356,12 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
|||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_product_parse_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
max_output_tokens=8192,
|
||||
max_output_tokens=4096,
|
||||
temperature=0.0,
|
||||
),
|
||||
)
|
||||
raw = response.text
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="LLM returned an empty response")
|
||||
# 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]
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
parsed = json.loads(response.text)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -53,43 +53,30 @@ pct enter 200 # or SSH into the container
|
|||
|
||||
```bash
|
||||
apt update && apt upgrade -y
|
||||
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5
|
||||
apt install -y git nginx curl ca-certificates gnupg lsb-release
|
||||
```
|
||||
|
||||
### Python 3.12+ + uv
|
||||
|
||||
```bash
|
||||
apt install -y python3 python3-venv
|
||||
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
source $HOME/.local/bin/env # or re-login
|
||||
```
|
||||
|
||||
Installing to `/usr/local/bin` makes `uv` available system-wide (required for `sudo -u innercontext uv sync`).
|
||||
|
||||
### Node.js 24 LTS + pnpm
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
nvm install 24
|
||||
corepack enable pnpm
|
||||
```
|
||||
|
||||
Copy `node` to `/usr/local/bin` so it is accessible system-wide
|
||||
(required for `sudo -u innercontext` and for systemd).
|
||||
Symlinking into `/root/.nvm/` won't work — other users can't traverse `/root/`.
|
||||
Use `--remove-destination` to replace any existing symlink with a real file:
|
||||
Create a stable symlink so systemd can find `node` at a fixed path:
|
||||
|
||||
```bash
|
||||
cp --remove-destination "$(nvm which current)" /usr/local/bin/node
|
||||
```
|
||||
|
||||
Install pnpm as a standalone binary from GitHub releases — self-contained,
|
||||
no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm`
|
||||
(the shim requires its nvm directory structure and breaks when copied/linked):
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \
|
||||
-o /usr/local/bin/pnpm
|
||||
chmod 755 /usr/local/bin/pnpm
|
||||
ln -sf "$(nvm which current)" /usr/local/bin/node
|
||||
```
|
||||
|
||||
### Application user
|
||||
|
|
@ -152,8 +139,6 @@ sudo -u innercontext uv sync
|
|||
```bash
|
||||
cat > /opt/innercontext/backend/.env <<'EOF'
|
||||
DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-lxc-ip>/innercontext
|
||||
GEMINI_API_KEY=your-gemini-api-key
|
||||
# GEMINI_MODEL=gemini-flash-latest # optional, this is the default
|
||||
EOF
|
||||
chmod 600 /opt/innercontext/backend/.env
|
||||
chown innercontext:innercontext /opt/innercontext/backend/.env
|
||||
|
|
@ -205,7 +190,6 @@ cd /opt/innercontext/frontend
|
|||
```bash
|
||||
cat > /opt/innercontext/frontend/.env.production <<'EOF'
|
||||
PUBLIC_API_BASE=http://innercontext.lan/api
|
||||
ORIGIN=http://innercontext.lan
|
||||
EOF
|
||||
chmod 600 /opt/innercontext/frontend/.env.production
|
||||
chown innercontext:innercontext /opt/innercontext/frontend/.env.production
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createSkinSnapshot, deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
|
||||
import { createSkinSnapshot, getSkinSnapshots } from '$lib/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
|
|
@ -50,59 +50,5 @@ export const actions: Actions = {
|
|||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = form.get('id') as string;
|
||||
const snapshot_date = form.get('snapshot_date') as string;
|
||||
const overall_state = form.get('overall_state') as string;
|
||||
const texture = form.get('texture') as string;
|
||||
const notes = form.get('notes') as string;
|
||||
const hydration_level = form.get('hydration_level') as string;
|
||||
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||
const barrier_state = form.get('barrier_state') as string;
|
||||
const active_concerns_raw = form.get('active_concerns') as string;
|
||||
const skin_type = form.get('skin_type') as string;
|
||||
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
|
||||
const active_concerns = active_concerns_raw
|
||||
?.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const body: Record<string, unknown> = { active_concerns };
|
||||
if (snapshot_date) body.snapshot_date = snapshot_date;
|
||||
if (overall_state) body.overall_state = overall_state;
|
||||
if (texture) body.texture = texture;
|
||||
if (notes) body.notes = notes;
|
||||
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||
if (barrier_state) body.barrier_state = barrier_state;
|
||||
if (skin_type) body.skin_type = skin_type;
|
||||
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||
|
||||
try {
|
||||
await updateSkinSnapshot(id, body);
|
||||
return { updated: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = form.get('id') as string;
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
try {
|
||||
await deleteSkinSnapshot(id);
|
||||
return { deleted: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
let showForm = $state(false);
|
||||
|
||||
// Create form state (bound to inputs so AI can pre-fill)
|
||||
// Form state (bound to inputs so AI can pre-fill)
|
||||
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
||||
let overallState = $state('');
|
||||
let texture = $state('');
|
||||
|
|
@ -38,36 +38,6 @@
|
|||
let activeConcernsRaw = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// Edit state
|
||||
let editingId = $state<string | null>(null);
|
||||
let editSnapshotDate = $state('');
|
||||
let editOverallState = $state('');
|
||||
let editTexture = $state('');
|
||||
let editBarrierState = $state('');
|
||||
let editSkinType = $state('');
|
||||
let editHydrationLevel = $state('');
|
||||
let editSensitivityLevel = $state('');
|
||||
let editSebumTzone = $state('');
|
||||
let editSebumCheeks = $state('');
|
||||
let editActiveConcernsRaw = $state('');
|
||||
let editNotes = $state('');
|
||||
|
||||
function startEdit(snap: (typeof data.snapshots)[number]) {
|
||||
editingId = snap.id;
|
||||
editSnapshotDate = snap.snapshot_date;
|
||||
editOverallState = snap.overall_state ?? '';
|
||||
editTexture = snap.texture ?? '';
|
||||
editBarrierState = snap.barrier_state ?? '';
|
||||
editSkinType = snap.skin_type ?? '';
|
||||
editHydrationLevel = snap.hydration_level != null ? String(snap.hydration_level) : '';
|
||||
editSensitivityLevel = snap.sensitivity_level != null ? String(snap.sensitivity_level) : '';
|
||||
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
|
||||
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
|
||||
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
|
||||
editNotes = snap.notes ?? '';
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
// AI photo analysis state
|
||||
let aiPanelOpen = $state(false);
|
||||
let selectedFiles = $state<File[]>([]);
|
||||
|
|
@ -131,12 +101,6 @@
|
|||
{#if form?.created}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
||||
{/if}
|
||||
{#if form?.updated}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot updated.</div>
|
||||
{/if}
|
||||
{#if form?.deleted}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot deleted.</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<!-- AI photo analysis card -->
|
||||
|
|
@ -320,167 +284,9 @@
|
|||
{#each sortedSnapshots as snap (snap.id)}
|
||||
<Card>
|
||||
<CardContent class="pt-4">
|
||||
{#if editingId === snap.id}
|
||||
<!-- Inline edit form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingId = null;
|
||||
}}
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_snapshot_date">Date *</Label>
|
||||
<Input
|
||||
id="edit_snapshot_date"
|
||||
name="snapshot_date"
|
||||
type="date"
|
||||
bind:value={editSnapshotDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Overall state</Label>
|
||||
<input type="hidden" name="overall_state" value={editOverallState} />
|
||||
<Select
|
||||
type="single"
|
||||
value={editOverallState}
|
||||
onValueChange={(v) => (editOverallState = v)}
|
||||
>
|
||||
<SelectTrigger>{editOverallState || 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each states as s (s)}
|
||||
<SelectItem value={s}>{s}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Texture</Label>
|
||||
<input type="hidden" name="texture" value={editTexture} />
|
||||
<Select
|
||||
type="single"
|
||||
value={editTexture}
|
||||
onValueChange={(v) => (editTexture = v)}
|
||||
>
|
||||
<SelectTrigger>{editTexture || 'Select'}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTextures as t (t)}
|
||||
<SelectItem value={t}>{t}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Skin type</Label>
|
||||
<input type="hidden" name="skin_type" value={editSkinType} />
|
||||
<Select
|
||||
type="single"
|
||||
value={editSkinType}
|
||||
onValueChange={(v) => (editSkinType = v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
>{editSkinType ? editSkinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger
|
||||
>
|
||||
<SelectContent>
|
||||
{#each skinTypes as st (st)}
|
||||
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Barrier state</Label>
|
||||
<input type="hidden" name="barrier_state" value={editBarrierState} />
|
||||
<Select
|
||||
type="single"
|
||||
value={editBarrierState}
|
||||
onValueChange={(v) => (editBarrierState = v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
>{editBarrierState
|
||||
? editBarrierState.replace(/_/g, ' ')
|
||||
: 'Select'}</SelectTrigger
|
||||
>
|
||||
<SelectContent>
|
||||
{#each barrierStates as b (b)}
|
||||
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_hydration_level">Hydration (1–5)</Label>
|
||||
<Input
|
||||
id="edit_hydration_level"
|
||||
name="hydration_level"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editHydrationLevel}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sensitivity_level">Sensitivity (1–5)</Label>
|
||||
<Input
|
||||
id="edit_sensitivity_level"
|
||||
name="sensitivity_level"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSensitivityLevel}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sebum_tzone">Sebum T-zone (1–5)</Label>
|
||||
<Input
|
||||
id="edit_sebum_tzone"
|
||||
name="sebum_tzone"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSebumTzone}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sebum_cheeks">Sebum cheeks (1–5)</Label>
|
||||
<Input
|
||||
id="edit_sebum_cheeks"
|
||||
name="sebum_cheeks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSebumCheeks}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_active_concerns">Active concerns (comma-separated)</Label>
|
||||
<Input
|
||||
id="edit_active_concerns"
|
||||
name="active_concerns"
|
||||
placeholder="acne, redness, dehydration"
|
||||
bind:value={editActiveConcernsRaw}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_notes">Notes</Label>
|
||||
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="button" variant="outline" onclick={() => (editingId = null)}
|
||||
>Cancel</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Read view -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-medium">{snap.snapshot_date}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2">
|
||||
{#if snap.overall_state}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
||||
|
|
@ -493,25 +299,6 @@
|
|||
{#if snap.texture}
|
||||
<Badge variant="secondary">{snap.texture}</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => startEdit(snap)}
|
||||
class="h-7 px-2 text-xs"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||
|
|
@ -544,7 +331,6 @@
|
|||
{#if snap.notes}
|
||||
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue