upd recognition

This commit is contained in:
beo3000 2026-03-05 19:32:05 +01:00
parent 66a0aeaae2
commit bdd1b7d67c
12 changed files with 2342 additions and 13003 deletions

View File

@ -1 +1 @@
1.2.22
1.2.24

View File

@ -13,7 +13,7 @@
import { scopeColor, scopeIcon } from '$lib/stores/scopeContext';
import {
CalendarDays, CalendarRange, Users, FolderOpen, Building2, User, BookOpen,
LayoutList, Archive, Trash2, Settings, HardDrive, LogOut,
Archive, Trash2, Settings, HardDrive, LogOut,
ChevronDown, Home, RefreshCw, Package
} from 'lucide-svelte';
@ -39,10 +39,20 @@
const companies = $derived(($contexts ?? []).filter((c: AgendaContext) => c.type === 'company' && c.isFavorite));
const people = $derived(($contexts ?? []).filter((c: AgendaContext) => c.type === 'person' && c.isFavorite));
let collapsed: Record<string, boolean> = $state({ user: true });
const STORAGE_KEY = 'sidebar-collapsed';
function loadCollapsed(): Record<string, boolean> {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : { user: true };
} catch {
return { user: true };
}
}
let collapsed: Record<string, boolean> = $state(loadCollapsed());
function toggleSection(key: string) {
collapsed[key] = !collapsed[key];
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed)); } catch {}
}
function navigate(id: string) {
@ -64,10 +74,10 @@
: 'mb-1 w-full rounded px-3 py-2.5 text-left text-sm border-l-2 border-transparent text-[#aaa] hover:bg-white/5 hover:text-white transition-colors';
}
// Section header: ▼ toggle (small) + label (navigates to overview)
// Section header: label (navigates to overview) + ▼ toggle (small) on the right
const sectionHeader = 'flex items-center gap-2 mt-6 mb-1 pl-1 pr-1';
const sectionToggle = 'text-xs text-muted hover:text-white transition-colors flex-shrink-0 px-2 py-1.5';
const sectionLabel = 'text-sm text-muted hover:text-white flex items-center gap-1.5 transition-colors flex-1 text-left font-semibold';
const sectionToggle = 'text-xs text-muted hover:text-white transition-colors flex-shrink-0 px-1 py-1.5 ml-auto';
const sectionLabel = 'text-sm text-muted hover:text-white flex items-center gap-1.5 transition-colors text-left font-semibold';
</script>
{#if !hideLogo}
@ -78,38 +88,37 @@
</div>
{/if}
<!-- General -->
<div class="mb-1 mt-6 pl-1 text-sm text-muted flex items-center gap-2 font-semibold">
<Home size={15} class="flex-shrink-0 text-slate-400" /> General
<!-- Home -->
<div class={sectionHeader}>
<button class={sectionLabel} onclick={() => navigate('daily-log')}>
<Home size={15} class="flex-shrink-0 text-slate-400" /> Home
</button>
<button class={sectionToggle} onclick={() => toggleSection('home')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['home'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['home']}
<ul class="m-0 list-none p-0">
{#if dailyLog}
<li>
<button class={navItem(currentId === 'daily-log') + ' flex items-center gap-2'} onclick={() => navigate('daily-log')}>
<LayoutList size={15} class="flex-shrink-0 text-slate-400" />
<span class="truncate">{dailyLog.name}</span>
</button>
</li>
{/if}
<li>
<button
class={navItem(page.url.pathname.startsWith('/journal/archive')) + ' flex items-center gap-2'}
onclick={() => { goto('/journal/archive'); onnavigate?.(); }}
>
<Archive size={15} class="flex-shrink-0 text-slate-400" />
<Archive size={14} class="flex-shrink-0 text-slate-400/70" />
<span class="truncate">Archiv</span>
</button>
</li>
</ul>
{/if}
<!-- Jour Fixes -->
<div class={sectionHeader}>
<button class={sectionToggle} onclick={() => toggleSection('meetings')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['meetings'] ? '-rotate-90' : ''}" />
</button>
<button class={sectionLabel} onclick={() => { goto('/meetings'); onnavigate?.(); }}>
<CalendarRange size={15} class="flex-shrink-0 text-violet-400" /> Jour Fixes
</button>
<button class={sectionToggle} onclick={() => toggleSection('meetings')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['meetings'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['meetings']}
<ul class="m-0 list-none p-0">
@ -126,12 +135,12 @@
<!-- Projects -->
<div class={sectionHeader}>
<button class={sectionToggle} onclick={() => toggleSection('projects')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['projects'] ? '-rotate-90' : ''}" />
</button>
<button class={sectionLabel} onclick={() => { goto('/projects'); onnavigate?.(); }}>
<FolderOpen size={15} class="flex-shrink-0 text-amber-400" /> Projects
</button>
<button class={sectionToggle} onclick={() => toggleSection('projects')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['projects'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['projects']}
<ul class="m-0 list-none p-0">
@ -148,12 +157,12 @@
<!-- Companies -->
<div class={sectionHeader}>
<button class={sectionToggle} onclick={() => toggleSection('companies')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['companies'] ? '-rotate-90' : ''}" />
</button>
<button class={sectionLabel} onclick={() => { goto('/companies'); onnavigate?.(); }}>
<Building2 size={15} class="flex-shrink-0 text-sky-400" /> Firmen
</button>
<button class={sectionToggle} onclick={() => toggleSection('companies')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['companies'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['companies']}
<ul class="m-0 list-none p-0">
@ -170,12 +179,12 @@
<!-- People -->
<div class={sectionHeader}>
<button class={sectionToggle} onclick={() => toggleSection('people')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['people'] ? '-rotate-90' : ''}" />
</button>
<button class={sectionLabel} onclick={() => { goto('/persons'); onnavigate?.(); }}>
<Users size={15} class="flex-shrink-0 text-emerald-400" /> People
</button>
<button class={sectionToggle} onclick={() => toggleSection('people')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['people'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['people']}
<ul class="m-0 list-none p-0">
@ -191,29 +200,54 @@
{/if}
<!-- Inventar -->
<div class="mb-1 mt-6 pl-1 text-sm text-muted flex items-center gap-2 font-semibold">
<Package size={15} class="flex-shrink-0 text-teal-400" /> Inventar
<div class={sectionHeader}>
<button class={sectionLabel} onclick={() => { goto('/inventory'); onnavigate?.(); }}>
<Package size={15} class="flex-shrink-0 text-teal-400" /> Inventar
</button>
<button class={sectionToggle} onclick={() => toggleSection('inventory')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['inventory'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['inventory']}
<ul class="m-0 list-none p-0">
<li>
<button
class={navItem(page.url.pathname.startsWith('/inventory')) + ' flex items-center gap-2'}
class={navItem(page.url.pathname === '/inventory') + ' flex items-center gap-2'}
onclick={() => { goto('/inventory'); onnavigate?.(); }}
>
<Package size={15} class="flex-shrink-0 text-teal-400/70" />
<span class="truncate">Inventar</span>
<span class="truncate">Dashboard</span>
</button>
</li>
<li>
<button
class={navItem(page.url.pathname.startsWith('/inventory/rooms')) + ' flex items-center gap-2'}
onclick={() => { goto('/inventory/rooms'); onnavigate?.(); }}
>
<Package size={14} class="flex-shrink-0 text-teal-400/60" />
<span class="truncate">Alle Räume</span>
</button>
</li>
<li>
<button
class={navItem(page.url.pathname.startsWith('/inventory/items')) + ' flex items-center gap-2'}
onclick={() => { goto('/inventory/items'); onnavigate?.(); }}
>
<Package size={14} class="flex-shrink-0 text-teal-400/60" />
<span class="truncate">Alle Gegenstände</span>
</button>
</li>
</ul>
{/if}
<!-- Wiki -->
<div class={sectionHeader}>
<button class={sectionToggle} onclick={() => toggleSection('wiki')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['wiki'] ? '-rotate-90' : ''}" />
</button>
<button class={sectionLabel} onclick={() => { goto('/wiki'); onnavigate?.(); }}>
<BookOpen size={15} class="flex-shrink-0 text-orange-400" /> Wiki
</button>
<button class={sectionToggle} onclick={() => toggleSection('wiki')} title="Auf-/Zuklappen">
<ChevronDown size={14} class="transition-transform {collapsed['wiki'] ? '-rotate-90' : ''}" />
</button>
</div>
{#if !collapsed['wiki']}
<ul class="m-0 list-none p-0">

View File

@ -2,10 +2,13 @@ import { getAccessToken } from '$lib/auth/authStore';
const API_BASE = import.meta.env.VITE_API_URL ?? '';
export type RecognizeErrorCode = 'no_api_key' | 'rate_limited' | 'error';
export interface RecognizeResult {
label: string;
category: string;
candidates: string[];
errorCode?: RecognizeErrorCode;
}
export async function compressImage(blob: Blob): Promise<Blob> {
@ -55,11 +58,12 @@ export async function recognizePhoto(blob: Blob): Promise<RecognizeResult> {
form.append('image', compressed, 'photo.jpg');
try {
const res = await authFetch(`${API_BASE}/api/vision/recognize`, { method: 'POST', body: form });
if (res.status === 422 || res.status === 429) return FALLBACK_RESULT;
if (!res.ok) return FALLBACK_RESULT;
if (res.status === 422) return { ...FALLBACK_RESULT, errorCode: 'no_api_key' };
if (res.status === 429) return { ...FALLBACK_RESULT, errorCode: 'rate_limited' };
if (!res.ok) return { ...FALLBACK_RESULT, errorCode: 'error' };
return res.json() as Promise<RecognizeResult>;
} catch {
return FALLBACK_RESULT;
return { ...FALLBACK_RESULT, errorCode: 'error' };
}
}

View File

@ -11,8 +11,11 @@
import { getImageUrl } from '$lib/db/imageStore';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { ChevronLeft, Package, Plus, Trash2 } from 'lucide-svelte';
import { ChevronLeft, Package, Plus, Trash2, ScanSearch } from 'lucide-svelte';
import DarkSelect from '$lib/components/DarkSelect.svelte';
import LabelConfirm from '$lib/components/LabelConfirm.svelte';
import { recognizePhoto } from '$lib/services/visionService';
import type { RecognizeResult } from '$lib/services/visionService';
import type { Asset, AssetImage, AssetPerson } from '@ka-note/shared';
import type { AgendaContext } from '@ka-note/shared';
@ -27,6 +30,9 @@
let imageUrls = $state<Map<string, string>>(new Map());
let confirmDelete = $state(false);
let uploading = $state(false);
let recognizing = $state(false);
let recognizeResult = $state<RecognizeResult | null>(null);
let recognizeError = $state('');
const CATEGORIES = [
'', 'Elektronik', 'Möbel', 'Kleidung', 'Schmuck', 'Kunstwerke',
@ -73,6 +79,29 @@
asset = updated;
}
async function recognizeWithCover() {
if (!asset?.coverImageId) return;
recognizing = true;
recognizeError = '';
recognizeResult = null;
try {
const record = await db.imageBlobs.get(asset.coverImageId);
if (!record) { recognizeError = 'Bild nicht gefunden.'; return; }
const result = await recognizePhoto(record.data);
if (result.errorCode === 'no_api_key') { recognizeError = 'Kein API-Key hinterlegt (Einstellungen → Vision).'; return; }
if (result.errorCode === 'rate_limited') { recognizeError = 'Tageslimit für KI-Erkennung erreicht.'; return; }
if (result.errorCode === 'error') { recognizeError = 'KI-Erkennung fehlgeschlagen (Netzwerk/Server).'; return; }
recognizeResult = result;
} finally {
recognizing = false;
}
}
async function applyRecognizeResult(label: string, category: string) {
recognizeResult = null;
await save({ title: label, category: category || null });
}
async function toggleStatus() {
if (!asset) return;
await save({ status: asset.status === 'draft' ? 'complete' : 'draft' });
@ -208,15 +237,50 @@
{/each}
</div>
{/if}
<!-- Upload button -->
<label class="mb-6 flex cursor-pointer items-center gap-2 text-sm text-muted hover:text-white transition-colors">
{#if uploading}
<span class="animate-spin"></span> Hochladen…
{:else}
<Plus size={16} /> Foto hinzufügen
<!-- Upload + Recognize buttons -->
<div class="mb-6 flex items-center gap-4 flex-wrap">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted hover:text-white transition-colors">
{#if uploading}
<span class="animate-spin"></span> Hochladen…
{:else}
<Plus size={16} /> Foto hinzufügen
{/if}
<input type="file" accept="image/*" class="hidden" onchange={handleImageFile} disabled={uploading} />
</label>
{#if asset.coverImageId}
<button
class="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors disabled:opacity-40"
onclick={recognizeWithCover}
disabled={recognizing}
>
{#if recognizing}
<span class="animate-spin"></span> Erkenne…
{:else}
<ScanSearch size={16} /> Erkennen
{/if}
</button>
{/if}
<input type="file" accept="image/*" class="hidden" onchange={handleImageFile} disabled={uploading} />
</label>
</div>
{#if recognizeError}
<div class="mb-4 rounded-lg bg-red-500/20 border border-red-500/40 px-3 py-2 text-xs text-red-300">
{recognizeError}
</div>
{/if}
{#if recognizeResult}
<div class="mb-4 rounded-xl border border-accent/30 bg-accent/10 p-3">
<p class="text-xs text-muted mb-2">KI-Vorschlag:</p>
<LabelConfirm
label={recognizeResult.label}
category={recognizeResult.category}
candidates={recognizeResult.candidates}
onconfirm={applyRecognizeResult}
oncancel={() => recognizeResult = null}
/>
</div>
{/if}
<!-- Fields -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 mb-4">

View File

@ -273,6 +273,18 @@
{#if capturedPreview}
<img src={capturedPreview} alt="" class="max-h-32 max-w-full object-contain rounded-xl mb-4" />
{/if}
{#if recognized.errorCode}
<div class="w-full max-w-sm mb-3 rounded-lg bg-yellow-500/20 border border-yellow-500/40 px-3 py-2 text-xs text-yellow-300">
{#if recognized.errorCode === 'no_api_key'}
⚠ Kein API-Key hinterlegt — KI-Erkennung nicht verfügbar.
{:else if recognized.errorCode === 'rate_limited'}
⚠ Tageslimit für KI-Erkennung erreicht.
{:else}
⚠ KI-Erkennung fehlgeschlagen (Netzwerk/Server).
{/if}
<br/>Bezeichnung auf der Detailseite mit dem "Erkennen"-Button nachholen.
</div>
{/if}
<div class="w-full max-w-sm">
<LabelConfirm
label={recognized.label}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,13 +0,0 @@
import { readFileSync, writeFileSync } from 'fs';
let sql = readFileSync('dump-fixed.sql', 'utf8');
// unistr('\uXXXX') -> plain UTF-8 string
sql = sql.replace(/unistr\('((?:[^'\\]|\\.)*)'\)/g, (_, inner) => {
const decoded = inner.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
);
return `'${decoded}'`;
});
// ROLLBACK at end of corrupt dump -> COMMIT so import doesn't discard everything
sql = sql.replace(/^ROLLBACK;.*$/m, 'COMMIT;');
writeFileSync('dump-fixed2.sql', sql, 'utf8');
console.log('done');

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -29,7 +29,7 @@ function parseVisionResponse(text: string): RecognizeResult {
}
class OpenAIVisionProvider implements VisionProvider {
constructor(private apiKey: string) {}
constructor(private apiKey: string) { }
async recognize(base64: string, mime: string): Promise<RecognizeResult> {
const controller = new AbortController();
@ -61,14 +61,14 @@ class OpenAIVisionProvider implements VisionProvider {
}
class GeminiVisionProvider implements VisionProvider {
constructor(private apiKey: string) {}
constructor(private apiKey: string) { }
async recognize(base64: string, mime: string): Promise<RecognizeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${this.apiKey}`;
const res = await fetch(url, {
// const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${this.apiKey}`;
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=${this.apiKey}`; const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,