upd recognition
This commit is contained in:
parent
66a0aeaae2
commit
bdd1b7d67c
|
|
@ -1 +1 @@
|
|||
1.2.22
|
||||
1.2.24
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue