Persist offer assistant jobs and recover results across closed views
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import React,{useEffect,useState,useRef} from 'react';
|
||||
import {listAiJobs,getAiJob,cancelAiJob,retryAiJob} from '../services/aiJobs';
|
||||
const labels={queued:'Sırada',running:'İşleniyor',completed:'Tamamlandı',failed:'Hata',interrupted:'Kesildi',cancelled:'İptal edildi'};
|
||||
const operations={'belge-sor':'Belge sorusu','teklif-denetim':'Taslak denetimi','teklif-taslak':'Taslak üretimi'};
|
||||
export function AiJobHistory({ikn,onOpen,revision,locked=false}) {
|
||||
const [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false);
|
||||
const alive=useRef(true);
|
||||
useEffect(()=>{alive.current=true;return()=>{alive.current=false;};},[]);
|
||||
const act=async fn=>{
|
||||
if(busy)return;setBusy(true);setError(null);
|
||||
try {await fn();}catch(e){if(alive.current)setError(e.message);}finally {if(alive.current)setBusy(false);}
|
||||
};
|
||||
const refresh=async()=>{const data=await listAiJobs(ikn);if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');if(alive.current)setJobs(data.jobs);};
|
||||
useEffect(()=>{void act(refresh);},[ikn,revision]);
|
||||
return <details className="border rounded-xl p-3 text-xs">
|
||||
<summary className="font-bold cursor-pointer">Kayıtlı AI işleri</summary>
|
||||
<p className="mt-2">Ekran kapansa da sunucudaki işler devam eder. Son 50 iş gösterilir. Kesilen işi yeniden başlatmak sağlayıcıya yeni istek gönderir. Eski sonucu açmak asistanın mevcut metinlerini değiştirir.</p>
|
||||
<button onClick={()=>act(refresh)} disabled={busy} className="border rounded px-3 py-2 mt-2">İş listesini yenile</button>
|
||||
{busy&&<p role="status">İş bilgileri alınıyor…</p>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{!jobs.length&&!busy&&!error&&<p>Bu ihale için kayıtlı AI işi yok.</p>}
|
||||
<ul className="space-y-2 mt-2">{jobs.map(job=><li key={job.id} className="border rounded p-2">
|
||||
<p>{operations[job.operation]} · {labels[job.state]} · {new Date(job.createdAt).toLocaleString('tr-TR')}</p>
|
||||
{job.error&&<p>{job.error}</p>}
|
||||
{job.state==='completed'&&<button disabled={busy || locked} className="underline py-2 mr-3" onClick={()=>act(async()=>{const data=await getAiJob(job.id);if(alive.current)onOpen(data.job);})}>Kaynak ve sonucu aç</button>}
|
||||
{['failed','interrupted'].includes(job.state)&&<button disabled={busy} className="underline py-2 mr-3" onClick={()=>act(async()=>{await retryAiJob(job.id);await refresh();})}>İşi yeniden başlat</button>}
|
||||
{['queued','running'].includes(job.state)&&<button disabled={busy} className="underline py-2" onClick={()=>act(async()=>{await cancelAiJob(job.id);await refresh();})}>İşi iptal et</button>}
|
||||
</li>)}</ul>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AiJobHistory } from './AiJobHistory';
|
||||
import { validateOfferResponse } from '../services/offerResponse';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
|
||||
import { getToken } from '../services/auth';
|
||||
@@ -30,7 +32,7 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
const lifetime = useRef(new AbortController());
|
||||
useEffect(() => { live.current = true; lifetime.current = new AbortController(); return () => { live.current = false; lifetime.current.abort(); }; }, []);
|
||||
const current = () => live.current && Boolean(session.current) && getToken() === session.current;
|
||||
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal});
|
||||
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal,background:true,onProgress:job=>{if(current())setJobProgress(job);}});
|
||||
const [arac, setArac] = useState('sor');
|
||||
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
@@ -44,13 +46,26 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
const [draftSource, setDraftSource] = useState(null);
|
||||
const [mesgul, setMesgul] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
const [jobProgress,setJobProgress]=useState(null);
|
||||
const [jobRevision,setJobRevision]=useState(0);
|
||||
const openJob=job=>{
|
||||
if(!current() || mesgul)return;
|
||||
if(job?.ikn!==tender.ikn || job?.state!=='completed')throw new Error('Tamamlanmış iş bu ihaleye ait değil.');
|
||||
const result=validateOfferResponse('/api/ai/'+job.operation,job.result);
|
||||
const input=job.input || {};
|
||||
setMetin(input.text || '');setSourceName(input.text?'Kayıtlı AI işi kaynağı':'');
|
||||
setCevap(null);setDenetim(null);setBolumler([]);setDraftReview(null);
|
||||
if(job.operation==='belge-sor'){setArac('sor');setSoru(input.soru || '');setCevap(result);}
|
||||
if(job.operation==='teklif-denetim'){setArac('denet');setTaslak(input.taslak || '');setDenetim(result);}
|
||||
if(job.operation==='teklif-taslak'){setArac('uret');setTur(input.tur || 'tam');setBolumler(result.bolumler);setDraftReview(result.draftReview);setDraftSource(input.text || '');}
|
||||
};
|
||||
|
||||
const calistir = async (fn) => {
|
||||
if (busy.current || !current()) return;
|
||||
busy.current = true; setMesgul(true); setHata(null);
|
||||
try { await fn(); }
|
||||
catch (e) { if (current()) setHata(e.message); }
|
||||
finally { busy.current = false; if (current()) setMesgul(false); }
|
||||
finally { busy.current = false; if (current()) {setMesgul(false);setJobRevision(n=>n+1);} }
|
||||
};
|
||||
const sor = (q) => calistir(async () => {
|
||||
const result = await post('/api/ai/belge-sor', {ikn:tender.ikn, soru:q, text:metin || undefined, tender});
|
||||
@@ -96,6 +111,8 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
</div>
|
||||
{metin && <button type="button" disabled={mesgul} className="text-xs text-blue-700 underline" onClick={() => {setMetin('');setSourceName('');setCevap(null);setDenetim(null);}}>Yüklenen kaynağı kaldır ve ilan kaynağını kullan</button>}
|
||||
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtların kaynak parçalarını kontrol edin; AI yanıtları hata veya eksik bilgi içerebilir.</p>
|
||||
<AiJobHistory ikn={tender.ikn} onOpen={openJob} revision={jobRevision} locked={mesgul} />
|
||||
{mesgul && jobProgress && <p role="status" className="text-xs text-blue-800">{jobProgress.state==='queued'?'AI işi sırada.':'AI işi sunucuda işleniyor.'} Ekranı kapatırsanız sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.</p>}
|
||||
{hata && <p role="alert" className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2">{hata}</p>}
|
||||
|
||||
{arac === 'sor' && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {accountRequest} from './accountRequest.js';
|
||||
import {awaitWithAbort} from './abortable.js';
|
||||
export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({ikn}));
|
||||
export const getAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}));
|
||||
export const cancelAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'DELETE'});
|
||||
export const retryAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'POST',body:JSON.stringify({action:'retry'})});
|
||||
export async function runDurableAiJob(operation,input,{signal,check,onProgress=()=>{},request=accountRequest,pollMs=1500}={}) {
|
||||
check();
|
||||
const started=await request('/api/me/ai-jobs',{method:'POST',signal,body:JSON.stringify({operation,input,requestId:crypto.randomUUID()})});
|
||||
let job=started.job;
|
||||
while(true) {
|
||||
check();
|
||||
if(!job || !['queued','running','completed','failed','interrupted','cancelled'].includes(job.state))throw new Error('AI iş durumu geçersiz. Kayıtlı işler bölümünden kontrol edin.');
|
||||
onProgress(job);
|
||||
if(job.state==='completed')return job.result;
|
||||
if(['failed','interrupted','cancelled'].includes(job.state))throw new Error(job.error || 'AI işi iptal edildi.');
|
||||
let timer;
|
||||
try {await awaitWithAbort(new Promise(resolve=>{timer=setTimeout(resolve,pollMs);}),signal,()=>clearTimeout(timer));}
|
||||
finally {clearTimeout(timer);}
|
||||
check();
|
||||
job=(await request('/api/me/ai-jobs?'+new URLSearchParams({id:job.id}),{signal})).job;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runDurableAiJob } from './aiJobs.js';
|
||||
import { validateOfferResponse } from './offerResponse.js';
|
||||
/** Bind the entire multi-step request to its starting account and mounted view. */
|
||||
export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000, signal}) {
|
||||
export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000, signal, background = false, onProgress}) {
|
||||
const token = readToken();
|
||||
const check = () => {
|
||||
signal?.throwIfAborted();
|
||||
@@ -19,6 +20,10 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = (
|
||||
.map(b => ({ad:b.ad, kategori:b.kategori}))};
|
||||
}
|
||||
check();
|
||||
if(background) {
|
||||
const data=await runDurableAiJob(url.split('/').at(-1),payload,{signal,check,onProgress});
|
||||
check();return validateOfferResponse(url,data);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort();
|
||||
signal?.addEventListener('abort',cancel,{once:true});
|
||||
|
||||
Reference in New Issue
Block a user