ollama experiment
This commit is contained in:
@@ -318,6 +318,7 @@ const DetailPanel = ({
|
|||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
activePreviewId = null,
|
activePreviewId = null,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
|
onUpdateIssuedAt = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
@@ -325,6 +326,8 @@ const DetailPanel = ({
|
|||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
resolveApiPath,
|
resolveApiPath,
|
||||||
onFolderNavigate = null,
|
onFolderNavigate = null,
|
||||||
|
ollamaUrl = null,
|
||||||
|
ollamaModel = 'llama3',
|
||||||
resolveFolderPath = null,
|
resolveFolderPath = null,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
@@ -354,6 +357,12 @@ const DetailPanel = ({
|
|||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
const [ocrError, setOcrError] = useState(null);
|
const [ocrError, setOcrError] = useState(null);
|
||||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
const ocrTextCacheRef = useRef({ docId: null, text: null });
|
||||||
|
const [aiSuggestion, setAiSuggestion] = useState(null);
|
||||||
|
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
|
||||||
|
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
|
||||||
|
const [aiSuggestionError, setAiSuggestionError] = useState(null);
|
||||||
|
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
@@ -387,6 +396,15 @@ const DetailPanel = ({
|
|||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [selectionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ocrTextCacheRef.current = { docId: null, text: null };
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
setAiSuggestionLoading(false);
|
||||||
|
setAiSuggestionApplying(false);
|
||||||
|
}, [singleDoc?.id]);
|
||||||
|
|
||||||
const startTitleEdit = useCallback(() => {
|
const startTitleEdit = useCallback(() => {
|
||||||
if (!singleDoc) return;
|
if (!singleDoc) return;
|
||||||
setTitleEditDocId(singleDoc.id);
|
setTitleEditDocId(singleDoc.id);
|
||||||
@@ -440,6 +458,40 @@ const DetailPanel = ({
|
|||||||
[singleDoc, getDocumentAsset],
|
[singleDoc, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveOllamaUrl = useMemo(
|
||||||
|
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
|
||||||
|
[ollamaUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveOllamaModel = useMemo(
|
||||||
|
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
|
||||||
|
[ollamaModel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSuggestTitle = Boolean(singleDoc);
|
||||||
|
const suggestionDisabledReason = useMemo(() => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
return 'Select a single document to generate suggestions.';
|
||||||
|
}
|
||||||
|
if (!hasOcrAsset) {
|
||||||
|
return 'OCR text is required to generate suggestions.';
|
||||||
|
}
|
||||||
|
if (!effectiveOllamaUrl) {
|
||||||
|
return 'Set an Ollama URL to enable AI suggestions.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
|
||||||
|
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
|
||||||
|
|
||||||
|
const canApplyAiSuggestion = useMemo(() => {
|
||||||
|
if (!aiSuggestion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
|
||||||
|
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
|
||||||
|
return applyTitle || applyIssuedAt;
|
||||||
|
}, [aiSuggestion, aiSuggestionSelection]);
|
||||||
|
|
||||||
const loadOcrUrl = useCallback(async () => {
|
const loadOcrUrl = useCallback(async () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return;
|
return;
|
||||||
@@ -482,6 +534,138 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
const fetchOcrText = useCallback(async () => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
throw new Error('No document selected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = ocrTextCacheRef.current;
|
||||||
|
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
|
||||||
|
return cached.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('No OCR text available for this document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = asset;
|
||||||
|
let url = entry?.url ||
|
||||||
|
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url && typeof ensureAssetUrl === 'function') {
|
||||||
|
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
||||||
|
if (ensured) {
|
||||||
|
entry = ensured;
|
||||||
|
}
|
||||||
|
url = entry?.url ||
|
||||||
|
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('OCR text URL is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('OCR text is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
|
||||||
|
return trimmed;
|
||||||
|
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
const extractSuggestionPayload = useCallback((raw) => {
|
||||||
|
const tryParse = (input) => {
|
||||||
|
if (!input) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(input);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimmed = (raw || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = tryParse(trimmed);
|
||||||
|
if (!payload) {
|
||||||
|
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
|
||||||
|
if (fenced) {
|
||||||
|
payload = tryParse(fenced[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
const firstObject = trimmed.match(/\{[\s\S]*\}/);
|
||||||
|
if (firstObject) {
|
||||||
|
payload = tryParse(firstObject[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof payload.title === 'string' ? payload.title : null;
|
||||||
|
const issuedAt =
|
||||||
|
typeof payload.issued_at === 'string'
|
||||||
|
? payload.issued_at
|
||||||
|
: typeof payload.issuedAt === 'string'
|
||||||
|
? payload.issuedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { title, issuedAt };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizeIssuedAtSuggestion = useCallback((input) => {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = String(input).trim();
|
||||||
|
if (!raw || raw.toLowerCase() === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
|
||||||
|
let year;
|
||||||
|
let month;
|
||||||
|
let day;
|
||||||
|
if (isoMatch) {
|
||||||
|
year = isoMatch[1];
|
||||||
|
month = isoMatch[2];
|
||||||
|
day = isoMatch[3];
|
||||||
|
} else {
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(parsed);
|
||||||
|
year = String(date.getUTCFullYear());
|
||||||
|
month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T00:00:00Z`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openOcrModal = useCallback(() => {
|
const openOcrModal = useCallback(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return;
|
return;
|
||||||
@@ -494,6 +678,154 @@ const DetailPanel = ({
|
|||||||
setOcrOpen(false);
|
setOcrOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSuggestTitle = useCallback(async () => {
|
||||||
|
if (!singleDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestionDisabledReason) {
|
||||||
|
setAiSuggestionError(suggestionDisabledReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiSuggestionLoading(true);
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
try {
|
||||||
|
const ocrText = await fetchOcrText();
|
||||||
|
const snippetLimit = 6000;
|
||||||
|
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}…` : ocrText;
|
||||||
|
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
|
||||||
|
{
|
||||||
|
"title": "<short descriptive title in the document's original language or null>",
|
||||||
|
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
|
||||||
|
}
|
||||||
|
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
|
||||||
|
|
||||||
|
OCR TEXT:
|
||||||
|
${trimmedOcr}`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: effectiveOllamaModel,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!suggestionResponse.ok) {
|
||||||
|
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await suggestionResponse.json();
|
||||||
|
const raw = (data?.response || data?.text || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('Model returned an empty response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = extractSuggestionPayload(raw);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Unable to parse suggestion response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedTitle = payload.title
|
||||||
|
? payload.title
|
||||||
|
.replace(/^['"\s]+/, '')
|
||||||
|
.replace(/['"\s]+$/, '')
|
||||||
|
.replace(/[.!?]+$/, '')
|
||||||
|
.trim()
|
||||||
|
: null;
|
||||||
|
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
|
||||||
|
|
||||||
|
if (!cleanedTitle && !normalizedIssuedAt) {
|
||||||
|
throw new Error('No usable title or date found in the response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSuggestion({
|
||||||
|
title: cleanedTitle || null,
|
||||||
|
issuedAt: normalizedIssuedAt,
|
||||||
|
raw,
|
||||||
|
model: effectiveOllamaModel,
|
||||||
|
});
|
||||||
|
setAiSuggestionSelection({
|
||||||
|
title: Boolean(cleanedTitle),
|
||||||
|
issuedAt: Boolean(normalizedIssuedAt),
|
||||||
|
});
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
|
||||||
|
} finally {
|
||||||
|
setAiSuggestionLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
singleDoc,
|
||||||
|
effectiveOllamaUrl,
|
||||||
|
effectiveOllamaModel,
|
||||||
|
fetchOcrText,
|
||||||
|
extractSuggestionPayload,
|
||||||
|
normalizeIssuedAtSuggestion,
|
||||||
|
suggestionDisabledReason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleApplySuggestion = useCallback(async () => {
|
||||||
|
if (!singleDoc || !aiSuggestion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
|
||||||
|
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
|
||||||
|
|
||||||
|
if (!applyTitle && !applyIssuedAt) {
|
||||||
|
setAiSuggestionError('Select at least one suggestion to apply.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSuggestionApplying(true);
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
try {
|
||||||
|
let titleOk = true;
|
||||||
|
let issuedOk = true;
|
||||||
|
|
||||||
|
if (applyTitle) {
|
||||||
|
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyIssuedAt) {
|
||||||
|
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleOk && issuedOk) {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
} else {
|
||||||
|
const failures = [];
|
||||||
|
if (!titleOk && applyTitle) failures.push('title');
|
||||||
|
if (!issuedOk && applyIssuedAt) failures.push('date');
|
||||||
|
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAiSuggestionApplying(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
singleDoc,
|
||||||
|
aiSuggestion,
|
||||||
|
aiSuggestionSelection,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssuedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
|
setAiSuggestion(null);
|
||||||
|
setAiSuggestionSelection({ title: true, issuedAt: true });
|
||||||
|
setAiSuggestionError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const singlePreviewNavigator = useAssetNavigator({
|
const singlePreviewNavigator = useAssetNavigator({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
assetType: 'preview',
|
assetType: 'preview',
|
||||||
@@ -1056,10 +1388,87 @@ const DetailPanel = ({
|
|||||||
>
|
>
|
||||||
<EditIcon className="icon-inline" />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
|
{canSuggestTitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary detail-title-suggest-button"
|
||||||
|
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
|
||||||
|
disabled={suggestButtonDisabled}
|
||||||
|
title={
|
||||||
|
aiSuggestionLoading
|
||||||
|
? 'Generating suggestion…'
|
||||||
|
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
{aiSuggestionError ? (
|
||||||
|
<div className="status-inline error">{aiSuggestionError}</div>
|
||||||
|
) : null}
|
||||||
|
{aiSuggestionLoading && !aiSuggestion ? (
|
||||||
|
<div className="status-inline">Generating title suggestion…</div>
|
||||||
|
) : null}
|
||||||
|
{aiSuggestion ? (
|
||||||
|
<div className="detail-title-suggestion" role="status" aria-live="polite">
|
||||||
|
<div className="detail-title-suggestion__label">AI suggestions</div>
|
||||||
|
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
|
||||||
|
<div className="detail-title-suggestion__options">
|
||||||
|
<label className="detail-title-suggestion__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
|
||||||
|
disabled={!aiSuggestion.title || aiSuggestionApplying}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAiSuggestionSelection((previous) => ({
|
||||||
|
...previous,
|
||||||
|
title: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Title:</strong>{' '}
|
||||||
|
{aiSuggestion.title || <em>Not available</em>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="detail-title-suggestion__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
|
||||||
|
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAiSuggestionSelection((previous) => ({
|
||||||
|
...previous,
|
||||||
|
issuedAt: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Issued date:</strong>{' '}
|
||||||
|
{aiSuggestion.issuedAt
|
||||||
|
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
|
||||||
|
: <em>Not available</em>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="detail-title-suggestion__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplySuggestion}
|
||||||
|
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
|
||||||
|
>
|
||||||
|
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>Uploaded:</strong>{' '}
|
<strong>Uploaded:</strong>{' '}
|
||||||
|
|||||||
+55
-1
@@ -55,7 +55,26 @@ const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
|||||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
|
||||||
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
const ENV = typeof process !== 'undefined' && process?.env ? process.env : {};
|
||||||
|
|
||||||
|
const API_ROOT = (runtimeApiBase || ENV.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
|
||||||
|
const runtimeOllamaUrl =
|
||||||
|
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_URL
|
||||||
|
? String(window.__PAPERCRATE_OLLAMA_URL)
|
||||||
|
: '';
|
||||||
|
const envOllamaUrl = (ENV.OLLAMA_BASE_URL || '').trim();
|
||||||
|
const rawOllamaUrl = (runtimeOllamaUrl || envOllamaUrl || DEFAULT_OLLAMA_URL).trim();
|
||||||
|
const OLLAMA_BASE_URL = rawOllamaUrl ? rawOllamaUrl.replace(/\/$/, '') : null;
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_MODEL = 'gpt-oss';
|
||||||
|
const runtimeOllamaModel =
|
||||||
|
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_MODEL
|
||||||
|
? String(window.__PAPERCRATE_OLLAMA_MODEL)
|
||||||
|
: '';
|
||||||
|
const envOllamaModel = (ENV.OLLAMA_MODEL || '').trim();
|
||||||
|
const OLLAMA_MODEL = (runtimeOllamaModel || envOllamaModel || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
|
||||||
@@ -3551,6 +3570,38 @@ const AppLayout = () => {
|
|||||||
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDocumentIssuedAtUpdate = useCallback(
|
||||||
|
async (documentId, nextIssuedAt) => {
|
||||||
|
const payload = {
|
||||||
|
issued_at: nextIssuedAt ? new Date(nextIssuedAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||||
|
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||||
|
const hydratedDocument = hydratedDetail?.document || data.document || data;
|
||||||
|
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (hydratedDocument) {
|
||||||
|
return { ...doc, ...hydratedDocument };
|
||||||
|
}
|
||||||
|
return { ...doc, issued_at: payload.issued_at };
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMessage('Document issued date updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[assetManager, api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||||
|
);
|
||||||
|
|
||||||
const applyTagRemovalToCaches = useCallback(
|
const applyTagRemovalToCaches = useCallback(
|
||||||
(documentId, tagId) => {
|
(documentId, tagId) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
@@ -4889,6 +4940,7 @@ const AppLayout = () => {
|
|||||||
onPromoteSelection: promoteSelectionOrder,
|
onPromoteSelection: promoteSelectionOrder,
|
||||||
activePreviewId,
|
activePreviewId,
|
||||||
onUpdateTitle: handleDocumentTitleUpdate,
|
onUpdateTitle: handleDocumentTitleUpdate,
|
||||||
|
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -4898,6 +4950,8 @@ const AppLayout = () => {
|
|||||||
onFolderNavigate: selectFolder,
|
onFolderNavigate: selectFolder,
|
||||||
onClose: clearDocumentSelection,
|
onClose: clearDocumentSelection,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
|
ollamaUrl: OLLAMA_BASE_URL,
|
||||||
|
ollamaModel: OLLAMA_MODEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
const skeuoWorkspaceProps = useMemo(
|
const skeuoWorkspaceProps = useMemo(
|
||||||
|
|||||||
@@ -983,7 +983,8 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return () => {};
|
if (!container) return () => {};
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
const isDevEnv = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
|
||||||
|
if (isDevEnv) {
|
||||||
console.log('[skeuo] canvas element', container);
|
console.log('[skeuo] canvas element', container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1840,6 +1840,60 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggest-button {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-title-suggestion__option input[type='checkbox'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path {
|
.detail-panel .detail-folder-path {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
|
||||||
|
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
|
||||||
|
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
|
|||||||
Reference in New Issue
Block a user