2026-05-16 08:34:43 +00:00

641 lines
24 KiB
JavaScript

const $ = (id) => document.getElementById(id);
const STORAGE_KEY = 'ssa_last_session_id';
const state = {
sessionId: null,
current: null,
drafts: [],
};
const stageLabels = {
intro: 'تمهيد',
exploration: 'استكشاف',
usecase: 'حالات الاستخدام',
evaluation: 'تقييم',
done: 'مكتمل',
};
document.addEventListener('DOMContentLoaded', init);
function init() {
$('start-btn').addEventListener('click', startSession);
$('project-title').addEventListener('keydown', (e) => { if (e.key === 'Enter') startSession(); });
$('send-btn').addEventListener('click', sendMessage);
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
$('input').addEventListener('input', autoGrow);
$('srs-text').addEventListener('input', autoGrow);
$('actors-editor').addEventListener('input', autoGrow);
$('usecases-editor').addEventListener('input', autoGrow);
$('io-editor').addEventListener('input', autoGrow);
$('scope-editor').addEventListener('input', autoGrow);
$('analyze-srs-btn').addEventListener('click', analyzeSrs);
$('confirm-srs-btn').addEventListener('click', confirmStructure);
$('save-planning-btn').addEventListener('click', savePlanning);
$('export-btn').addEventListener('click', () => {
if (state.sessionId) window.open(`/report/${state.sessionId}`, '_blank');
});
$('srs-file').addEventListener('change', () => {
const file = $('srs-file').files?.[0];
$('srs-file-name').textContent = file ? `الملف المختار: ${file.name}` : 'لم يتم اختيار ملف بعد.';
});
loadDrafts();
}
function autoGrow(event) {
const target = event.target;
if (!target || target.tagName !== 'TEXTAREA') return;
target.style.height = 'auto';
target.style.height = Math.min(target.scrollHeight, target.classList.contains('editor-textarea') ? 220 : 160) + 'px';
}
async function loadDrafts() {
try {
const res = await fetch('/api/sessions');
const data = await res.json();
state.drafts = Array.isArray(data.sessions) ? data.sessions : [];
renderDraftLists();
} catch (error) {
console.error('loadDrafts error', error);
}
}
function renderDraftLists() {
renderDraftList('welcome-drafts', true);
renderDraftList('sidebar-drafts', false);
}
function renderDraftList(containerId, compact = false) {
const container = $(containerId);
const drafts = state.drafts || [];
const lastId = localStorage.getItem(STORAGE_KEY);
if (!drafts.length) {
container.innerHTML = '<div class="draft-empty">لا توجد مسودات محفوظة بعد.</div>';
return;
}
container.innerHTML = drafts.map((draft) => {
const active = draft.id === state.sessionId;
const last = draft.id === lastId;
const counts = draft.counts || {};
return `
<button class="draft-card${active ? ' active' : ''}${last ? ' last-opened' : ''}" data-session-id="${esc(draft.id)}">
<div class="draft-card-top">
<div class="draft-title">${esc(draft.title || 'مسودة بدون عنوان')}</div>
${last && !active ? '<span class="tiny-badge">آخر جلسة</span>' : ''}
</div>
<div class="draft-meta">${esc(draft.checkpointLabel || draft.stage || '—')}</div>
<div class="draft-stats">
<span>${counts.useCases || 0} UC</span>
<span>${counts.functionPoints || 0} FP</span>
<span>${counts.inputOutputs || 0} IO</span>
</div>
<div class="draft-date">${formatDate(draft.updatedAt, compact)}</div>
</button>
`;
}).join('');
container.querySelectorAll('.draft-card').forEach((btn) => {
btn.addEventListener('click', () => resumeSession(btn.dataset.sessionId));
});
}
function formatDate(value, compact = false) {
if (!value) return 'بدون تاريخ';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'بدون تاريخ';
const formatter = new Intl.DateTimeFormat('ar-EG', {
year: 'numeric',
month: compact ? 'short' : 'long',
day: 'numeric',
hour: compact ? undefined : 'numeric',
minute: compact ? undefined : '2-digit',
});
return formatter.format(date);
}
function showApp(title) {
$('welcome').classList.add('hidden');
$('app').classList.remove('hidden');
$('project-name').textContent = title || '—';
}
function showResumeBanner(text) {
const banner = $('resume-banner');
banner.textContent = text;
banner.classList.remove('hidden');
}
function hideResumeBanner() {
$('resume-banner').classList.add('hidden');
$('resume-banner').textContent = '';
}
async function startSession() {
const title = $('project-title').value.trim();
if (!title) {
$('project-title').focus();
return;
}
$('start-btn').disabled = true;
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'فشل بدء الجلسة');
state.sessionId = data.sessionId;
localStorage.setItem(STORAGE_KEY, data.sessionId);
state.current = data;
clearMessages();
hideResumeBanner();
showApp(title);
addAgentMessage(data.reply || 'تم بدء الجلسة.', true);
updateState(data);
await loadDrafts();
$('input').focus();
} catch (error) {
alert(`خطأ: ${error.message}`);
} finally {
$('start-btn').disabled = false;
}
}
async function resumeSession(sessionId) {
try {
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'تعذر استئناف الجلسة');
state.sessionId = data.sessionId;
state.current = data;
localStorage.setItem(STORAGE_KEY, data.sessionId);
showApp(data.title);
renderHistory(data.history || []);
updateState(data);
showResumeBanner(data.resumeMessage || 'أهلاً بك مجدداً، يمكننا متابعة العمل من آخر نقطة توقفنا عندها.');
$('input').focus();
await loadDrafts();
} catch (error) {
alert(`خطأ: ${error.message}`);
if (String(error.message).includes('غير موجودة')) {
localStorage.removeItem(STORAGE_KEY);
}
}
}
async function sendMessage() {
const input = $('input');
const text = input.value.trim();
if (!text || !state.sessionId) return;
hideResumeBanner();
addUserMessage(text);
input.value = '';
input.style.height = 'auto';
$('send-btn').disabled = true;
const thinkingEl = addAgentMessage('يفكّر...', false, true);
try {
const res = await fetch('/api/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId, message: text }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'فشل الإرسال');
thinkingEl.remove();
addAgentMessage(data.reply || 'تم تحديث التحليل.', true);
updateState(data);
await loadDrafts();
} catch (error) {
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
thinkingEl.classList.remove('thinking');
} finally {
$('send-btn').disabled = false;
$('input').focus();
}
}
async function analyzeSrs() {
if (!state.sessionId) {
alert('ابدأ جلسة أولاً ثم ارفع ملف SRS أو الصق النص.');
return;
}
const file = $('srs-file').files?.[0];
let content = $('srs-text').value.trim();
let filename = '';
if (!content && file) {
filename = file.name;
content = await file.text();
} else if (file) {
filename = file.name;
}
if (!content.trim()) {
alert('أرفق ملفاً نصياً بسيطاً أو الصق نص المتطلبات أولاً.');
return;
}
if (looksBinary(content)) {
alert('الملف يبدو غير نصي. في هذه النسخة يُفضَّل رفع ملف نصي بسيط أو لصق محتوى SRS يدوياً.');
return;
}
addUserMessage(filename ? `📄 تم إرسال ملف SRS للتحليل: ${filename}` : '📄 تم إرسال نص SRS للتحليل');
const thinkingEl = addAgentMessage('أحلّل المستند وأستخرج Actors وUse Cases وInputs/Outputs...', false, true);
try {
const res = await fetch('/api/analyze-srs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId, content, filename }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'فشل تحليل المستند');
thinkingEl.remove();
addAgentMessage(data.reply || 'تم تحليل المستند.', true);
updateState(data);
await loadDrafts();
} catch (error) {
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
thinkingEl.classList.remove('thinking');
}
}
async function confirmStructure() {
if (!state.sessionId) return;
const actors = parseSimpleList($('actors-editor').value);
const useCases = parseUseCases($('usecases-editor').value, state.current?.useCases || []);
const inputOutputs = parseInputOutputs($('io-editor').value, state.current?.inputOutputs || []);
const scope = $('scope-editor').value.trim();
if (!actors.length && !useCases.length && !inputOutputs.length && !scope) {
alert('لا توجد بيانات لاعتمادها بعد. حلّل SRS أولاً أو أدخل القوائم يدوياً.');
return;
}
addUserMessage('✅ تم اعتماد/تعديل الهيكل الأولي للمشروع.');
const thinkingEl = addAgentMessage('أثبّت القوائم وأعيد حساب التقدير...', false, true);
try {
const res = await fetch('/api/confirm-structure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId, scope, actors, useCases, inputOutputs }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'فشل اعتماد الاقتراح');
thinkingEl.remove();
addAgentMessage(data.reply || 'تم اعتماد الهيكل الأولي.', true);
updateState(data);
await loadDrafts();
} catch (error) {
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
thinkingEl.classList.remove('thinking');
}
}
async function savePlanning() {
if (!state.sessionId) return;
$('planning-status').textContent = 'جارٍ تحديث خطة التنفيذ...';
try {
const res = await fetch(`/api/session/${encodeURIComponent(state.sessionId)}/planning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
developers: Number($('team-size').value || 3),
sprintWeeks: Number($('sprint-weeks').value || 2),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'فشل تحديث التخطيط');
updateState(data);
$('planning-status').textContent = 'تم تحديث الخطة والمدة المتوقعة بنجاح.';
await loadDrafts();
} catch (error) {
$('planning-status').textContent = `⚠️ ${error.message}`;
}
}
function clearMessages() {
$('messages').innerHTML = '';
}
function renderHistory(history) {
clearMessages();
history.forEach((entry) => {
if (entry.role === 'user') addUserMessage(entry.text || '', false);
else if (entry.role === 'assistant') addAgentMessage(entry.payload?.reply || '', false, false, false);
});
scrollMsgs();
}
function addUserMessage(text, scroll = true) {
const el = document.createElement('div');
el.className = 'msg user';
el.innerHTML = `<div class="avatar">أنا</div><div class="bubble"></div>`;
el.querySelector('.bubble').textContent = text;
$('messages').appendChild(el);
if (scroll) scrollMsgs();
return el;
}
function addAgentMessage(text, typewriter = false, thinking = false, scroll = true) {
document.querySelectorAll('.msg.agent.fresh').forEach((node) => node.classList.remove('fresh'));
const el = document.createElement('div');
el.className = `msg agent fresh${thinking ? ' thinking' : ''}`;
el.innerHTML = `<div class="avatar">AI</div><div class="bubble${thinking ? ' typing' : ''}"></div>`;
$('messages').appendChild(el);
const bubble = el.querySelector('.bubble');
if (typewriter) typeText(bubble, text);
else bubble.textContent = text;
if (scroll) scrollMsgs();
return el;
}
function typeText(el, text) {
el.textContent = '';
const cursor = document.createElement('span');
cursor.className = 'cursor';
el.appendChild(cursor);
let i = 0;
const speed = Math.max(8, Math.min(28, 1200 / Math.max(text.length, 12)));
const tick = () => {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text[i]);
i += 1;
scrollMsgs();
setTimeout(tick, speed);
} else {
cursor.remove();
}
};
tick();
}
function scrollMsgs() {
const messages = $('messages');
messages.scrollTop = messages.scrollHeight;
}
function updateState(data) {
state.current = data;
state.sessionId = data.sessionId || data.id || state.sessionId;
localStorage.setItem(STORAGE_KEY, state.sessionId);
const fps = data.functionPoints || [];
const ucs = data.useCases || [];
const ios = data.inputOutputs || [];
const actors = data.actors || [];
const analytics = data.analytics || {};
const planning = data.planning || {};
$('project-name').textContent = data.title || $('project-name').textContent;
$('fp-count').textContent = fps.length;
$('uc-count').textContent = ucs.length;
$('io-count').textContent = ios.length;
$('fp-total').textContent = analytics.fp?.totalFP ?? fps.reduce((sum, item) => sum + (Number(item.fpScore) || 0), 0);
$('stage-pill').textContent = stageLabels[data.stage] || data.stage || '—';
$('team-size').value = planning.developers || 3;
$('sprint-weeks').value = planning.sprintWeeks || 2;
fillProposalEditors(data);
renderTree(actors, fps, ios);
renderPreview(data);
if (data.isComplete || ucs.length >= 1 || fps.length >= 2) $('export-btn').classList.remove('hidden');
}
function fillProposalEditors(data) {
const source = data.srsDraft || data;
$('scope-editor').value = source.summary || source.scope || data.scope || '';
$('actors-editor').value = (source.actors || []).join('\n');
$('usecases-editor').value = (source.useCases || []).map((uc) => `${uc.title || ''} | ${uc.actor || 'المستخدم'}`).join('\n');
$('io-editor').value = (source.inputOutputs || []).map((item) => [
item.type === 'output' ? 'Output' : 'Input',
item.name || '',
item.source || '',
item.destination || '',
item.description || '',
].join(' | ')).join('\n');
const counts = source.counts || {
actors: (source.actors || []).length,
useCases: (source.useCases || []).length,
inputs: (source.inputOutputs || []).filter((item) => item.type === 'input').length,
outputs: (source.inputOutputs || []).filter((item) => item.type === 'output').length,
};
if (counts.actors || counts.useCases || counts.inputs || counts.outputs) {
$('proposal-summary').textContent = `Actors: ${counts.actors || 0} · Use Cases: ${counts.useCases || 0} · Inputs: ${counts.inputs || 0} · Outputs: ${counts.outputs || 0}`;
} else {
$('proposal-summary').textContent = 'ستظهر هنا أعداد Actors وUse Cases وInputs/Outputs بعد تحليل المستند.';
}
const status = $('proposal-status');
if (data.needsStructureConfirmation) {
status.textContent = 'بانتظار الاعتماد';
status.classList.remove('dim');
} else if ((source.actors || []).length || (source.useCases || []).length) {
status.textContent = 'قابل للتعديل';
status.classList.remove('dim');
} else {
status.textContent = 'بانتظار تحليل';
status.classList.add('dim');
}
}
function renderTree(actors, fps, ios) {
const tree = $('tree');
if (!actors.length && !fps.length && !ios.length) {
tree.innerHTML = '<div class="tree-empty">ستظهر الـ Actors والوظائف وهياكل الـ IO هنا تلقائياً.</div>';
return;
}
let html = '';
actors.forEach((actor) => {
html += `<div class="tree-actor">${esc(actor)}</div>`;
});
fps.forEach((fp) => {
html += `<div class="tree-fp">${esc(fp.id || '')} · ${esc(fp.name || '')}</div>`;
});
ios.slice(0, 8).forEach((item) => {
html += `<div class="tree-io ${item.type === 'output' ? 'out' : 'in'}">${item.type === 'output' ? 'Output' : 'Input'} · ${esc(item.name || '')}</div>`;
});
tree.innerHTML = html;
}
function renderPreview(data) {
const prev = $('preview-content');
const actors = data.actors || [];
const fps = data.functionPoints || [];
const ucs = data.useCases || [];
const ios = data.inputOutputs || [];
const analytics = data.analytics || {};
const fp = analytics.fp || {};
const ucp = analytics.ucp || {};
const discrepancy = analytics.discrepancy || {};
const plan = analytics.plan || {};
let html = '';
html += `<div class="summary-strip">
${previewMetric(fp.totalFP ?? 0, 'Total FP', `~ ${fp.effortHours ?? 0} ساعة`) }
${previewMetric(ucp.UCP ?? 0, 'UCP', `~ ${ucp.effortHours ?? 0} ساعة`) }
${previewMetric(discrepancy.deltaPercent ?? 0, 'Gap', `${discrepancy.dominantMethod || '—'} الأعلى`) }
${previewMetric(plan.totalSprints ?? 0, 'Sprints', `${plan.calendarWeeks ?? 0} أسبوع`) }
</div>`;
if (data.scope) {
html += `<h3>نطاق النظام</h3><div class="scope">${esc(data.scope)}</div>`;
}
if (actors.length) {
html += `<h3>Actors (${actors.length})</h3><div class="tag-row">${actors.map((actor) => `<span class="tag-chip">${esc(actor)}</span>`).join('')}</div>`;
}
if (ios.length) {
html += `<h3>Inputs / Outputs (${ios.length})</h3>`;
ios.forEach((item) => {
html += `<div class="io-prev ${item.type === 'output' ? 'out' : 'in'}"><b>${item.type === 'output' ? 'Output' : 'Input'}</b> ${esc(item.name || '')}<br><span>${esc(item.source || '')}${esc(item.destination || '')}</span></div>`;
});
}
if (fps.length) {
html += `<h3>المتطلبات الوظيفية (${fps.length})</h3>`;
fps.forEach((fpItem) => {
html += `<div class="uc-prev"><b>${esc(fpItem.id || '')}</b> ${esc(fpItem.name || '')} <span style="color:#888">— ${esc(fpItem.complexity || '')} (FP ${esc(String(fpItem.fpScore || ''))})</span></div>`;
});
}
if (ucs.length) {
html += `<h3>حالات الاستخدام (${ucs.length})</h3>`;
ucs.forEach((uc) => {
html += `<div class="uc-prev"><b>${esc(uc.id || '')}</b> ${esc(uc.title || '')}<br><span style="color:#888">الفاعل: ${esc(uc.actor || '')}</span></div>`;
});
}
if (discrepancy.narrative) {
html += `<h3>تحليل الفرق بين FP وUCP</h3>`;
html += `<div class="analysis-card">
<div class="analysis-line"><strong>الفرق:</strong> ${esc(String(discrepancy.deltaPercent ?? 0))}%</div>
<div class="analysis-line"><strong>الطريقة الأعلى:</strong> ${esc(discrepancy.dominantMethod || '—')}</div>
<div class="analysis-line">${esc(discrepancy.narrative || '')}</div>
${Array.isArray(discrepancy.evidence) && discrepancy.evidence.length ? `<ul>${discrepancy.evidence.map((item) => `<li>${esc(item)}</li>`).join('')}</ul>` : ''}
<div class="analysis-line"><strong>التوصية:</strong> ${esc(discrepancy.recommendation || '')}</div>
</div>`;
}
if (plan.milestones?.length) {
html += `<h3>WBS / Milestones</h3>`;
plan.milestones.forEach((item) => {
html += `<div class="milestone-item"><b>${esc(item.name || '')}</b><span>${esc(String(item.percent || 0))}% · ${esc(String(item.hours || 0))} ساعة · ${esc(item.owner || '')}</span></div>`;
});
}
if (plan.sprintPlan?.length) {
html += `<h3>Sprint Breakdown</h3>`;
plan.sprintPlan.forEach((sprint) => {
html += `<div class="sprint-card"><b>${esc(sprint.name || '')}</b><span>${esc(sprint.goal || '')}</span><ul>${(sprint.focus || []).map((item) => `<li>${esc(item)}</li>`).join('')}</ul></div>`;
});
}
if (plan.staffing?.length) {
html += `<h3>توزيع الفريق</h3>`;
plan.staffing.forEach((member) => {
html += `<div class="milestone-item"><b>${esc(member.name || '')}</b><span>${esc(member.focus || '')} · ${esc(String(member.loadHours || 0))} ساعة</span></div>`;
});
}
prev.innerHTML = html || '<div class="preview-empty">سيتشكل هنا ملخص النطاق، Catalog الـ IO، تحليل الفجوة بين FP/UCP، وخطة التنفيذ الأولية.</div>';
}
function previewMetric(value, label, meta) {
return `<div class="preview-metric"><div class="metric-label">${esc(label)}</div><div class="metric-value">${esc(String(value))}</div><div class="metric-meta">${esc(meta || '')}</div></div>`;
}
function parseSimpleList(text) {
return text
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
function parseUseCases(text, previous = []) {
return parseSimpleList(text).map((line, index) => {
const prev = previous[index] || {};
const parts = line.split('|').map((part) => part.trim());
const title = parts[0] || prev.title || `حالة استخدام ${index + 1}`;
const actor = parts[1] || prev.actor || 'المستخدم';
const mainFlow = parts[2]
? parts[2].split('>').map((step) => step.trim()).filter(Boolean)
: (Array.isArray(prev.mainFlow) && prev.mainFlow.length ? prev.mainFlow : defaultMainFlow(title, actor));
const alternateFlow = parts[3]
? parts[3].split('>').map((step) => step.trim()).filter(Boolean)
: (Array.isArray(prev.alternateFlow) ? prev.alternateFlow : []);
return {
id: prev.id || `UC-${String(index + 1).padStart(2, '0')}`,
title,
actor,
preconditions: prev.preconditions || `يملك ${actor} الصلاحية اللازمة.`,
mainFlow,
alternateFlow,
};
});
}
function parseInputOutputs(text, previous = []) {
return parseSimpleList(text).map((line, index) => {
const prev = previous[index] || {};
const parts = line.split('|').map((part) => part.trim());
const typeRaw = (parts[0] || prev.type || 'input').toLowerCase();
const type = /output|out|مخرج|إخراج/.test(typeRaw) ? 'output' : 'input';
return {
id: prev.id || `IO-${String(index + 1).padStart(2, '0')}`,
type,
name: parts[1] || prev.name || `${type === 'output' ? 'مخرج' : 'مدخل'} ${index + 1}`,
source: parts[2] || prev.source || (type === 'input' ? 'المستخدم' : 'النظام'),
destination: parts[3] || prev.destination || (type === 'input' ? 'النظام' : 'المستخدم'),
description: parts[4] || prev.description || '',
};
});
}
function defaultMainFlow(title, actor) {
return [
`${actor} يبدأ مسار ${title}.`,
'يدخل البيانات أو يحدد الخيارات المطلوبة.',
'يعالج النظام الطلب ويعرض النتيجة أو التأكيد.',
];
}
function looksBinary(text) {
if (!text) return false;
let suspicious = 0;
const sample = text.slice(0, 600);
for (let i = 0; i < sample.length; i += 1) {
const code = sample.charCodeAt(i);
if (code === 0 || (code < 9) || (code > 13 && code < 32)) suspicious += 1;
}
return suspicious / Math.max(sample.length, 1) > 0.12;
}
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
}