Site içine sohbet penceresi
Sitenizin köşesinde açılan, asistanınızla konuşan bir sohbet penceresi. Kopyalayıp yapıştırabileceğiniz düz HTML ve JavaScript ile React ve Next.js sürümü.
Bu sayfadaki iki tarif de eksiksizdir: akışı okur, sohbeti tarayıcıda saklar, ürün kartlarını ve düğmeleri çizer, sohbet ekibe geçince ekibin yanıtlarını gösterir.
Başlamadan önce Sonecentral'da bir tarayıcı anahtarı oluşturun ve sitenizin adresini anahtarın İzin verilen siteler listesine ekleyin. Geliştirme için http://localhost:3000 gibi bir adres de eklenebilir. Ayrıntılar: Kimlik doğrulama ve anahtarlar.
Düz HTML ve JavaScript
Hiçbir kütüphane gerekmez. Aşağıdaki üç parçayı sayfanızın </body> etiketinden hemen önce yapıştırın ve KEY değerini kendi tarayıcı anahtarınızla değiştirin.
<button id="sone-open" type="button" aria-expanded="false" aria-controls="sone-panel">Bize yazın</button>
<section id="sone-panel" hidden aria-label="Sohbet">
<header class="sone-head">
<strong>Bize yazın</strong>
<span>
<button id="sone-reset" type="button">Yeni sohbet</button>
<button id="sone-close" type="button" aria-label="Sohbeti kapat">Kapat</button>
</span>
</header>
<ol id="sone-log" aria-live="polite"></ol>
<p id="sone-status" hidden></p>
<form id="sone-form">
<textarea id="sone-input" rows="2" maxlength="4000" placeholder="Mesajınızı yazın" aria-label="Mesaj"></textarea>
<button type="submit">Gönder</button>
</form>
</section>#sone-open {
position: fixed; right: 20px; bottom: 20px; z-index: 50;
padding: 12px 18px; border: 0; border-radius: 999px;
background: #17123f; color: #fff; font: 600 15px/1 system-ui, sans-serif; cursor: pointer;
}
#sone-panel {
position: fixed; right: 20px; bottom: 76px; z-index: 50;
width: min(380px, calc(100vw - 40px)); height: min(580px, calc(100dvh - 110px));
display: flex; flex-direction: column; overflow: hidden;
background: #fff; color: #17123f; border-radius: 16px;
box-shadow: 0 2px 6px rgba(23, 18, 63, 0.08), 0 12px 32px rgba(23, 18, 63, 0.12);
font: 15px/1.45 system-ui, sans-serif;
}
#sone-panel[hidden] { display: none; }
.sone-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
.sone-head button { border: 0; background: none; color: #524d7a; font: inherit; font-size: 13px; cursor: pointer; }
#sone-log { flex: 1; overflow-y: auto; margin: 0; padding: 4px 16px 12px; list-style: none; display: flex; flex-direction: column; gap: 8px; }
.sone-row { display: flex; flex-direction: column; align-items: flex-start; }
.sone-row.sone-user { align-items: flex-end; }
.sone-text { margin: 0; max-width: 85%; padding: 8px 12px; border-radius: 14px; background: #f1eefb; white-space: pre-wrap; }
.sone-user .sone-text { background: #17123f; color: #fff; }
.sone-staff .sone-text::before { content: 'Ekip'; display: block; font-size: 12px; font-weight: 600; color: #524d7a; }
.sone-catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; width: 100%; }
.sone-item { display: flex; flex-direction: column; gap: 2px; padding: 10px; border-radius: 12px; background: #f7f5fd; }
.sone-item img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; margin-bottom: 4px; }
.sone-item span { font-size: 13px; color: #524d7a; }
.sone-item .sone-price { color: #17123f; font-weight: 600; }
.sone-item a, .sone-file { color: #4f31d6; font-size: 14px; font-weight: 600; }
.sone-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.sone-actions p { width: 100%; margin: 0 0 2px; }
.sone-actions button, .sone-actions a {
padding: 6px 12px; border: 1px solid #cfc5f7; border-radius: 999px;
background: #fff; color: #4f31d6; font: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
#sone-status { margin: 0; padding: 0 16px 8px; font-size: 13px; color: #524d7a; }
#sone-form { display: flex; gap: 8px; padding: 12px; background: #faf9fe; }
#sone-input { flex: 1; resize: none; border: 1px solid #ddd6f3; border-radius: 10px; padding: 8px 10px; font: inherit; }
#sone-form button { border: 0; border-radius: 10px; padding: 0 14px; background: #17123f; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }<script type="module">
const API = 'https://pb.sonecentral.com/api/v1';
const KEY = 'pk-sone-v1-TARAYICI_ANAHTARINIZ';
const MODEL = 'default';
const STORE = 'sone-chat-v1';
// Asistanın yanıt vermediği hatalar. Sohbet zaten açıksa mesaj ekibe ulaşmıştır.
const SENT_TO_TEAM = ['ai_turn_ceiling', 'assistant_quota_exhausted', 'plan_required', 'assistant_paused'];
const $ = (id) => document.getElementById(id);
const panel = $('sone-panel');
const log = $('sone-log');
const input = $('sone-input');
const statusLine = $('sone-status');
let chat = load();
let busy = false;
let pollTimer = null;
// team: sohbet ekipte. waiting: mesaj ekibe ulaştı, ekibin yanıtı bekleniyor.
// cursor: sohbette okunan son satırın kimliği.
function empty() {
return { conversationId: null, team: false, waiting: false, cursor: null, items: [] };
}
function load() {
try {
return { ...empty(), ...JSON.parse(localStorage.getItem(STORE)) };
} catch {
return empty();
}
}
function save() {
try {
localStorage.setItem(STORE, JSON.stringify(chat));
} catch {}
}
// Bütün metinler textContent ile yazılır; hiçbir alan sayfaya kod sokamaz.
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value == null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(typeof child === 'string' ? document.createTextNode(child) : child);
}
return node;
}
function renderMessage(message, current) {
if (message.type === 'catalog' && message.catalog) {
return el('div', { class: 'sone-catalog' }, message.catalog.items.map((item) =>
el('article', { class: 'sone-item' },
item.image_url ? el('img', { src: item.image_url, alt: '', loading: 'lazy' }) : null,
el('strong', {}, item.title),
item.detail ? el('span', {}, item.detail) : null,
item.price ? el('span', { class: 'sone-price' }, item.price) : null,
item.url ? el('a', { href: item.url, target: '_blank', rel: 'noopener' }, item.button_title || 'İncele') : null,
)));
}
if (message.type === 'actions' && message.actions) {
const { title, text, buttons = [], choices = [] } = message.actions;
return el('div', { class: 'sone-actions' },
title ? el('strong', {}, title) : null,
text ? el('p', { class: 'sone-text' }, text) : null,
buttons.map((button) => el('a', { href: button.url, target: '_blank', rel: 'noopener' }, button.title)),
// Seçenekler, ziyaretçi yeniden yazana kadar gösterilir.
current ? choices.map((choice) => el('button', { type: 'button', onclick: () => send(choice) }, choice)) : null);
}
if (message.type === 'file' && message.file?.url) {
return el('a', { class: 'sone-file', href: message.file.url, download: message.file.name }, message.file.name);
}
return el('p', { class: 'sone-text' }, message.text);
}
function render() {
const lastUser = chat.items.map((item) => item.role).lastIndexOf('user');
log.replaceChildren(...chat.items.map((item, index) => {
if (item.role === 'user') return el('li', { class: 'sone-row sone-user' }, el('p', { class: 'sone-text' }, item.text));
return el('li', { class: `sone-row sone-${item.message.role}` }, renderMessage(item.message, index > lastUser));
}));
log.scrollTop = log.scrollHeight;
}
function setStatus(text) {
statusLine.hidden = !text;
statusLine.textContent = text || '';
}
function idleStatus() {
if (chat.team) return 'Ekibimiz en kısa sürede burada yanıt verecek.';
if (chat.waiting) return 'Mesajınız ekibimize ulaştı, en kısa sürede buradan yanıt vereceğiz.';
return '';
}
// İşletme tarafının mesajlarını kimliğe göre bir kez ekler; kaç yeni mesaj eklendiğini döner.
function remember(messages) {
const known = new Set(chat.items.map((item) => item.message?.id));
let added = 0;
for (const message of messages) {
if (known.has(message.id)) continue;
known.add(message.id);
chat.items.push({ role: 'business', message });
chat.cursor = message.id;
added += 1;
}
return added;
}
// Chat Completions akışını okur. sone her zaman parçanın üst düzeyindedir:
// sone.message tamamlanan bir mesaj, sone.activity asistanın o an ne yaptığı,
// son parçadaki sone ise turun özeti.
async function readStream(response, on) {
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
let summary = null;
for (;;) {
const { value, done } = await reader.read();
if (done) return summary;
buffer += value.replace(/\r\n/g, '\n');
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue; // ": ping" satırları atlanır
const data = line.slice(5).trim();
if (data === '[DONE]') return summary;
const chunk = JSON.parse(data);
if (chunk.error) throw Object.assign(new Error(chunk.error.message), { code: chunk.error.code });
const choice = chunk.choices?.[0];
if (choice?.delta?.content) on.text(choice.delta.content);
if (chunk.sone?.activity) on.activity(chunk.sone.activity);
if (chunk.sone?.message) on.message(chunk.sone.message);
if (choice?.finish_reason === 'stop' && chunk.sone) summary = chunk.sone;
}
}
}
}
async function send(raw) {
const text = raw.trim();
if (!text || busy) return;
busy = true;
chat.items.push({ role: 'user', text });
save();
render();
// Akan metin bu satırda görünür; mesaj tamamlanınca yerini mesajın kendisi alır.
const live = el('p', { class: 'sone-text' });
const liveRow = el('li', { class: 'sone-row sone-assistant' }, live);
let problem = '';
setStatus('Yazıyor…');
try {
const response = await fetch(`${API}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
model: MODEL,
stream: true,
conversation_id: chat.conversationId ?? undefined,
messages: [{ role: 'user', content: text }],
}),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error(body?.error?.message || ''), { code: body?.error?.code });
}
// Akış başlarken sohbetin kimliği başlıkta gelir; akış yarıda kopsa da elinizde kalır.
chat.conversationId = response.headers.get('x-sone-conversation-id') || chat.conversationId;
const summary = await readStream(response, {
text(delta) {
if (!liveRow.isConnected) log.append(liveRow);
live.textContent += live.textContent ? delta : delta.trimStart();
log.scrollTop = log.scrollHeight;
},
activity(label) {
setStatus(`${label}…`);
},
message(message) {
liveRow.remove();
live.textContent = '';
remember([message]);
render();
},
});
if (summary) {
chat.conversationId = summary.conversation_id;
chat.team = summary.status === 'handoff' || summary.status === 'human';
chat.waiting = false;
}
} catch (error) {
if (error.code === 'conversation_not_found' || error.code === 'conversation_model_mismatch') {
// Saklanan sohbet artık kullanılamıyor: sonraki mesaj yeni bir sohbet açar.
Object.assign(chat, { conversationId: null, team: false, waiting: false, cursor: null });
}
if (SENT_TO_TEAM.includes(error.code) && chat.conversationId) chat.waiting = true;
// Sonecentral'ın hata metni Türkçedir; ağ hatalarında kendi metnimiz gösterilir.
else problem = (error.code && error.message) || 'Mesaj gönderilemedi. Biraz sonra yeniden deneyin.';
} finally {
liveRow.remove();
busy = false;
setStatus(problem || idleStatus());
syncPolling();
save();
render();
}
}
// Sohbet ekipteyken ya da mesaj ekibe ulaşıp yanıt beklenirken ekibin
// yazdıklarını okur. İmleç hep yanıttaki last_id'dir; ziyaretçinin kendi
// satırları atlanır ama imleci ilerletir.
async function poll() {
if (!chat.conversationId || busy || panel.hidden || document.hidden) return;
for (;;) {
const url = new URL(`${API}/conversations/${encodeURIComponent(chat.conversationId)}/messages`);
if (chat.cursor) url.searchParams.set('after', chat.cursor);
const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (response.status === 400) chat.cursor = null; // invalid_cursor: sonraki okuma baştan başlar
if (!response.ok) break;
const page = await response.json();
const added = remember(page.data.filter((message) => message.role !== 'user'));
chat.cursor = page.last_id ?? chat.cursor;
if (page.mode === 'team') chat.team = true;
if (added > 0) chat.waiting = false;
if (page.mode === 'assistant' && !chat.waiting) chat.team = false;
if (!page.has_more) break;
}
save();
render();
setStatus(idleStatus());
syncPolling();
}
function syncPolling() {
const wanted = Boolean(chat.conversationId) && (chat.team || chat.waiting);
if (wanted && !pollTimer) pollTimer = setInterval(() => poll().catch(() => {}), 5000);
if (!wanted && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
$('sone-open').addEventListener('click', () => {
const opening = panel.hidden;
panel.hidden = !opening;
$('sone-open').setAttribute('aria-expanded', String(opening));
if (opening) {
render();
setStatus(idleStatus());
input.focus();
if (chat.team || chat.waiting) poll().catch(() => {});
}
});
$('sone-close').addEventListener('click', () => {
panel.hidden = true;
$('sone-open').setAttribute('aria-expanded', 'false');
});
$('sone-reset').addEventListener('click', () => {
chat = empty();
syncPolling();
save();
setStatus('');
render();
});
$('sone-form').addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value;
input.value = '';
send(text);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
$('sone-form').requestSubmit();
}
});
syncPolling();
</script>Pencereyi kendi tasarımınıza uydurmak için yalnız CSS'i ve düğme yazılarını değiştirin. Asistanın paneldeki adını göstermek isterseniz GET /models yanıtındaki name alanını kullanabilirsiniz.
Bu tarif ne yapar
- Mesajı
POST /chat/completionsile akışlı gönderir ve metni geldikçe gösterir. Her mesaj tamamlanınca, parçanın üst düzeyindekisone.messageile o mesajı ürün kartı, düğme ya da dosya olarak yeniden çizer. - Sohbetin kimliğini gönderdiği için yalnız yeni mesajı yollar ve her mesajda yeni bir
Idempotency-Keygönderir. - Asistan bir araç kullanırken
sone.activitymetnini durum satırında gösterir. - Hızlı yanıtlara ve adresi olmayan düğmelere tıklanınca başlıklarını ziyaretçinin yeni mesajı olarak gönderir.
- Sohbetin kimliğini, gösterilen mesajları ve okunan son satırı
localStorage'da saklar. Sayfa yenilense de sohbet sürer; depolama kapalıysa sohbet o sekmede sürer. - Sohbet ekibe geçerse, yani
statushandoffya dahumangelirse, beş saniyede birGET /conversations/{id}/messages?after=ile ekibin yanıtlarını okur. İmleç olarak hep yanıttakilast_id'yi gönderir, ziyaretçinin kendi satırlarını çizmez vemode"assistant"olunca sormayı bırakır. Pencere kapalıyken ya da sekme arka plandayken sormaz. Aynı anda çok ziyaretçi ekip yanıtı bekleyebiliyorsa aralığı uzatın: Ne sıklıkla sormalı. - Açık bir sohbette asistan yanıt veremezse (
ai_turn_ceiling,assistant_quota_exhausted,plan_required,assistant_paused) mesajın ekibe ulaştığını söyler ve ekibin yanıtını bekler. - Saklanan sohbet artık bulunamazsa (
conversation_not_found) sonraki mesajda yeni bir sohbet açar. - Akış içinde gelen bir hata parçasını yakalar ve durum satırında gösterir.
React ve Next.js
Vercel AI SDK ile aynı pencere. Önce lib/sone.ts dosyasına Zengin mesajlar sayfasındaki SoneMessage, SoneCatalog, SoneActions ve SoneFile tiplerini ve Vercel AI SDK sayfasındaki SoneUIMessage tipini ekleyin.
npm i ai @ai-sdk/react'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useEffect, useState } from 'react';
import type { SoneMessage, SoneUIMessage } from '@/lib/sone';
const API = 'https://pb.sonecentral.com/api/v1';
const KEY = process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY!;
const STORE = 'sone-chat-v1';
const conversationOf = (messages: SoneUIMessage[]) =>
messages.findLast((m) => m.metadata?.conversationId)?.metadata?.conversationId;
const transport = new DefaultChatTransport<SoneUIMessage>({
api: `${API}/chat`,
headers: { Authorization: `Bearer ${KEY}` },
// Sohbetin kimliği varsa yalnız yeni mesaj gider; gerçek kayıt Sonecentral'dadır.
prepareSendMessagesRequest: ({ id, messages }) => {
const conversationId = conversationOf(messages);
return {
body: { id, model: 'default', conversationId, messages: conversationId ? messages.slice(-1) : messages },
};
},
});
// team: sohbet ekipte. cursor: sohbette okunan son satırın kimliği.
type Stored = { id: string; messages: SoneUIMessage[]; team: boolean; cursor: string | null };
type ThreadPage = {
data: SoneMessage[];
has_more: boolean;
mode: 'assistant' | 'team';
last_id?: string;
};
const fresh = (): Stored => ({ id: crypto.randomUUID(), messages: [], team: false, cursor: null });
function readStored(): Stored {
try {
const saved = JSON.parse(localStorage.getItem(STORE) ?? 'null');
if (saved?.id) return { team: false, cursor: null, ...saved };
} catch {}
return fresh();
}
// Ekipten birinin yazdığı mesajı, akışta gelecek veri parçasıyla aynı biçime çevirir.
function fromTeam(message: SoneMessage): SoneUIMessage {
const data = { text: message.text, role: 'staff' as const };
const part =
message.type === 'catalog'
? { type: 'data-sone-catalog' as const, id: message.id, data: { ...data, catalog: message.catalog } }
: message.type === 'actions'
? { type: 'data-sone-actions' as const, id: message.id, data: { ...data, actions: message.actions } }
: message.type === 'file'
? { type: 'data-sone-file' as const, id: message.id, data: { ...data, file: message.file } }
: { type: 'data-sone-staff' as const, id: message.id, data };
return { id: message.id, role: 'assistant', parts: [part] };
}
// Bir mesajın ekipten gelip gelmediği: veri parçalarının role alanına bakar.
function byTeam(message: SoneUIMessage) {
return message.parts.some((part) => 'data' in part && 'role' in part.data && part.data.role === 'staff');
}
export function SoneChat() {
const [stored, setStored] = useState(readStored);
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [activity, setActivity] = useState('');
const [team, setTeam] = useState(stored.team);
const [cursor, setCursor] = useState(stored.cursor);
// id değişince useChat yeni bir sohbet başlatır.
const { messages, setMessages, sendMessage, status, error } = useChat<SoneUIMessage>({
id: stored.id,
messages: stored.messages,
transport,
onData: (part) => {
if (part.type === 'data-sone-status') setActivity(part.data.label);
},
onError: () => setActivity(''),
onFinish: ({ message }) => {
setActivity('');
const turn = message.metadata?.status;
if (turn) setTeam(turn === 'handoff' || turn === 'human');
},
});
const busy = status === 'submitted' || status === 'streaming';
useEffect(() => {
if (busy) return;
try {
localStorage.setItem(STORE, JSON.stringify({ id: stored.id, messages, team, cursor }));
} catch {}
}, [busy, messages, team, cursor, stored.id]);
// Sohbet ekipteyken ekibin yazdıklarını okur. Asistanın satırları hep akıştan
// gelir; listeden yalnız ekibin satırları, kimliğe göre bir kez eklenir.
// İlk okuma sohbetin başından sayfa sayfa sona gider, sonrakiler imleçten sonrasını okur.
const conversationId = conversationOf(messages);
useEffect(() => {
if (!open || !team || busy || !conversationId) return;
const read = async () => {
if (document.hidden) return;
let next = cursor;
let mode: ThreadPage['mode'] = 'team';
const fromStaff: SoneMessage[] = [];
for (;;) {
const url = new URL(`${API}/conversations/${encodeURIComponent(conversationId)}/messages`);
if (next) url.searchParams.set('after', next);
const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }).catch(() => null);
if (response?.status === 400) setCursor(null); // invalid_cursor: sonraki okuma baştan başlar
if (!response?.ok) return;
const page = (await response.json()) as ThreadPage;
fromStaff.push(...page.data.filter((m) => m.role === 'staff'));
next = page.last_id ?? next;
mode = page.mode;
if (!page.has_more) break;
}
setMessages((current) => {
const known = new Set(current.flatMap((m) => [m.id, ...m.parts.map((p) => ('id' in p ? p.id : undefined))]));
const added = fromStaff.filter((m) => !known.has(m.id)).map(fromTeam);
return added.length > 0 ? [...current, ...added] : current;
});
setCursor(next);
if (mode === 'assistant') setTeam(false);
};
const timer = setInterval(read, 5000);
return () => clearInterval(timer);
}, [open, team, busy, conversationId, cursor, setMessages]);
const send = (text: string) => {
if (!text.trim() || busy) return;
sendMessage({ text });
};
const reset = () => {
const next = fresh();
try {
localStorage.removeItem(STORE);
} catch {}
setStored(next);
setTeam(false);
setCursor(null);
setActivity('');
};
// Sohbet ekipteyken bir tur yanıtsız biter; içi boş asistan mesajı çizilmez.
const shown = messages.filter((m) => m.parts.some((p) => p.type === 'text' || p.type.startsWith('data-')));
// Seçenekler, ziyaretçi yeniden yazana kadar gösterilir.
const lastUser = shown.map((m) => m.role).lastIndexOf('user');
return (
<>
<button className="sone-open" onClick={() => setOpen(!open)} aria-expanded={open}>
Bize yazın
</button>
{open ? (
<section className="sone-panel" aria-label="Sohbet">
<header className="sone-head">
<strong>Bize yazın</strong>
<button type="button" onClick={reset} disabled={busy}>
Yeni sohbet
</button>
</header>
<ol className="sone-log" aria-live="polite">
{shown.map((message, messageIndex) => (
<li key={message.id} className={`sone-row sone-${byTeam(message) ? 'staff' : message.role}`}>
{message.parts.map((part, index) => {
const key = `${message.id}-${index}`;
if (part.type === 'text') return <p key={key} className="sone-text">{part.text}</p>;
if (part.type === 'data-sone-staff') return <p key={key} className="sone-text">{part.data.text}</p>;
if (part.type === 'data-sone-catalog')
return (
<div key={key} className="sone-catalog">
{part.data.catalog?.items.map((item) => (
<article key={item.id} className="sone-item">
{item.image_url ? <img src={item.image_url} alt="" loading="lazy" /> : null}
<strong>{item.title}</strong>
{item.detail ? <span>{item.detail}</span> : null}
{item.price ? <span className="sone-price">{item.price}</span> : null}
{item.url ? (
<a href={item.url} target="_blank" rel="noopener">
{item.button_title || 'İncele'}
</a>
) : null}
</article>
))}
</div>
);
if (part.type === 'data-sone-actions') {
const current = messageIndex > lastUser;
const actions = part.data.actions;
return (
<div key={key} className="sone-actions">
{actions?.title ? <strong>{actions.title}</strong> : null}
{actions?.text ? <p className="sone-text">{actions.text}</p> : null}
{actions?.buttons?.map((button) => (
<a key={button.title} href={button.url} target="_blank" rel="noopener">
{button.title}
</a>
))}
{current
? actions?.choices?.map((choice) => (
<button key={choice} onClick={() => send(choice)}>
{choice}
</button>
))
: null}
</div>
);
}
if (part.type === 'data-sone-file')
return part.data.file?.url ? (
<a key={key} className="sone-file" href={part.data.file.url} download={part.data.file.name}>
{part.data.file.name}
</a>
) : (
<p key={key} className="sone-text">{part.data.text}</p>
);
return null;
})}
</li>
))}
</ol>
{activity ? <p className="sone-status">{activity}…</p> : null}
{team ? <p className="sone-status">Ekibimiz en kısa sürede burada yanıt verecek.</p> : null}
{error ? <p className="sone-status">{error.message || 'Mesaj gönderilemedi. Biraz sonra yeniden deneyin.'}</p> : null}
<form
className="sone-form"
onSubmit={(event) => {
event.preventDefault();
send(input);
setInput('');
}}
>
<textarea
className="sone-input"
value={input}
onChange={(event) => setInput(event.target.value)}
maxLength={4000}
rows={2}
placeholder="Mesajınızı yazın"
aria-label="Mesaj"
/>
<button type="submit">Gönder</button>
</form>
</section>
) : null}
</>
);
}Bu bileşen:
prepareSendMessagesRequestile, sohbetin kimliği elindeyse yalnız ziyaretçinin yeni mesajını veconversationId'yi gönderir. Mesajın kendiid'si gittiği için yeniden deneme güvenlidir.useChat'inid'sini, mesajları, sohbetin ekipte olup olmadığını ve okunan son satırılocalStorage'da saklar. Asistan mesajlarındakimetadata.conversationIdde mesajlarla birlikte saklanır.data-sone-*parçalarını çizer; hızlı yanıtları ve adresi olmayan düğmeleri yeni mesaj olarak gönderir;data-sone-status'uonDataile durum satırında gösterir.finishparçasındakistatushandoffya dahumanise beş saniyede birGET /conversations/{id}/messages?after=ile ekibin yanıtlarını okur vemode"assistant"olunca bırakır. Aralık için: Ne sıklıkla sormalı.- "Yeni sohbet" düğmesiyle yeni bir
idalır ve sıfırdan başlar. Saklanan sohbet artık bulunamazsa hata satırı bunu söyler; ziyaretçi bu düğmeyle yeni bir sohbete geçer.
Bileşen localStorage ve crypto kullandığı için yalnız tarayıcıda çalışmalı. Next.js'te dynamic ile ssr: false yalnız istemci bileşenlerinde kullanılabilir. Bu yüzden onu küçük bir 'use client' dosyasında yükleyin ve kök düzeninize o dosyayı koyun:
'use client';
import dynamic from 'next/dynamic';
const SoneChat = dynamic(() => import('./sone-chat').then((m) => m.SoneChat), { ssr: false });
export function SoneChatLoader() {
return <SoneChat />;
}import type { ReactNode } from 'react';
import { SoneChatLoader } from '@/components/sone-chat-loader';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="tr">
<body>
{children}
<SoneChatLoader />
</body>
</html>
);
}Sınıf adları yukarıdaki CSS ile aynıdır. CSS'teki #sone- ile başlayan kimlik seçicilerini aynı adlı sınıf seçicilerine çevirmeniz yeterli: #sone-panel yerine .sone-panel, #sone-log yerine .sone-log gibi.