Bind offer assistant requests and private context to their session

This commit is contained in:
2026-09-06 10:08:03 +03:00
parent 132bd815c6
commit ecc031a822
4 changed files with 114 additions and 21 deletions
+43 -21
View File
@@ -1,7 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
import { getToken } from '../services/auth';
import { listDocuments } from '../services/account';
import { useApp } from '../context/AppContext';
import { requestOfferAssistant } from '../services/offerAssistant';
/**
* Teklif Asistanı sekmesi — üç araç (İhaleAB'den taşındı):
@@ -10,17 +12,24 @@ import { listDocuments } from '../services/account';
* 3) Taslak Üret: kapak / teknik / ticari / tam dosya (rakam uydurmaz, [DOLDURUN])
* Doküman metni sunucuda EKAP'tan alınır; yoksa buradan .txt/.pdf metni yapıştırılır.
*/
const post = async (url, body) => {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: JSON.stringify(body) });
const d = await r.json().catch(() => ({}));
if (!r.ok || d.success === false) throw new Error(d.error || `HTTP ${r.status}`);
return d;
};
const ORNEKLER = ['Geçici teminat oranı ve süresi nedir?', 'Teslim süresi kaç gün?', 'Gecikme cezası ne kadar?', 'Hangi belgeler istenmiş?', 'Numune isteniyor mu?'];
const DURUM = { karsilaniyor: ['Karşılanıyor', 'bg-emerald-100 text-emerald-800 border-emerald-200'], eksik: ['Eksik', 'bg-red-100 text-red-800 border-red-200'], belirsiz: ['Belirsiz', 'bg-amber-100 text-amber-800 border-amber-200'] };
export const TeklifAsistani = ({ tender }) => {
const {authUser} = useApp();
const token = getToken();
const identity = useRef({token, revision:0});
if (identity.current.token !== token) identity.current = {token, revision:identity.current.revision + 1};
return <OfferAssistantSession key={`${authUser?.email || ''}:${tender.ikn || tender.id}:${identity.current.revision}`} tender={tender} />;
};
const OfferAssistantSession = ({ tender }) => {
const live = useRef(true);
const session = useRef(getToken());
const busy = useRef(false);
useEffect(() => { live.current = true; return () => { live.current = false; }; }, []);
const current = () => live.current && Boolean(session.current) && getToken() === session.current;
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents});
const [arac, setArac] = useState('sor');
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
const [soru, setSoru] = useState('');
@@ -32,21 +41,34 @@ export const TeklifAsistani = ({ tender }) => {
const [mesgul, setMesgul] = useState(false);
const [hata, setHata] = useState(null);
const calistir = async (fn) => { setMesgul(true); setHata(null); try { await fn(); } catch (e) { setHata(e.message); } finally { setMesgul(false); } };
const sor = (q) => calistir(async () => { setCevap(await post('/api/ai/belge-sor', { ikn: tender.ikn, soru: q, text: metin || undefined, tender })); });
const denetle = () => calistir(async () => { setDenetim(await post('/api/ai/teklif-denetim', { ikn: tender.ikn, taslak, tender, text: metin || undefined })); });
const uret = () => calistir(async () => {
const belgeler = await listDocuments().then((d) => (d.belgeler || []).map((b) => ({ ad: b.ad, kategori: b.kategori }))).catch(() => []);
setBolumler((await post('/api/ai/teklif-taslak', { ikn: tender.ikn, tur, tender, belgeler, text: metin || undefined })).bolumler || []);
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); }
};
const sor = (q) => calistir(async () => {
const result = await post('/api/ai/belge-sor', {ikn:tender.ikn, soru:q, text:metin || undefined, tender});
if (current()) setCevap(result);
});
const kopyala = (t) => navigator.clipboard?.writeText(t).catch(() => {});
const dosyaOku = async (f) => {
const denetle = () => calistir(async () => {
const result = await post('/api/ai/teklif-denetim', {ikn:tender.ikn, taslak, tender, text:metin || undefined});
if (current()) setDenetim(result);
});
const uret = () => calistir(async () => {
const result = await post('/api/ai/teklif-taslak', {ikn:tender.ikn, tur, tender, text:metin || undefined}, listDocuments);
if (current()) setBolumler(result.bolumler || []);
});
const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); };
const dosyaOku = (f) => {
if (!f) return;
try {
return calistir(async () => {
const { extractText } = await import('../services/pdfText');
if (!current()) return;
const r = await extractText(f);
setMetin(r.text || '');
} catch (e) { setHata('Dosya okunamadı: ' + e.message); }
if (current()) { setMetin(r.text || ''); setCevap(null); setDenetim(null); }
});
};
const Sekme = ({ id, icon: Icon, children }) => (
@@ -64,7 +86,7 @@ export const TeklifAsistani = ({ tender }) => {
<Sekme id="uret" icon={PenLine}>Taslak Üret</Sekme>
<label className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-600 cursor-pointer border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">
<Upload className="w-3.5 h-3.5" /> {metin ? `Doküman metni yüklendi (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'}
<input type="file" accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} />
<input type="file" disabled={mesgul} accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} />
</label>
</div>
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtlar yalnızca belgeden; rakamlar aynen aktarılır, model uydurmaz.</p>
@@ -76,7 +98,7 @@ export const TeklifAsistani = ({ tender }) => {
<input value={soru} onChange={(e) => setSoru(e.target.value)} placeholder="Ör. Geçici teminat oranı nedir?" className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm" />
<button type="submit" disabled={mesgul} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 rounded-lg">{mesgul ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Sor'}</button>
</form>
{!cevap && <div className="flex flex-wrap gap-1.5">{ORNEKLER.map((o) => <button key={o} type="button" onClick={() => { setSoru(o); sor(o); }} className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50">{o}</button>)}</div>}
{!cevap && <div className="flex flex-wrap gap-1.5">{ORNEKLER.map((o) => <button key={o} type="button" disabled={mesgul} onClick={() => { setSoru(o); sor(o); }} className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50">{o}</button>)}</div>}
{cevap && (
<div className="space-y-2">
<p className="text-sm text-slate-800 whitespace-pre-wrap leading-relaxed">{cevap.cevap}</p>