906 lines
32 KiB
JavaScript
906 lines
32 KiB
JavaScript
const $ = (id) => document.getElementById(id);
|
|
const STORAGE_KEY = 'ssa_last_session_id';
|
|
const state = {
|
|
sessionId: null,
|
|
current: null,
|
|
drafts: [],
|
|
ui: {
|
|
workbenchOpen: false,
|
|
activeTab: 'srs',
|
|
},
|
|
};
|
|
|
|
const stageLabels = {
|
|
intro: 'تمهيد',
|
|
exploration: 'استكشاف',
|
|
usecase: 'حالات الاستخدام',
|
|
evaluation: 'تقييم',
|
|
done: 'مكتمل',
|
|
};
|
|
|
|
const textareaLimits = {
|
|
input: { min: 46, max: 180 },
|
|
'srs-text': { min: 92, max: 240 },
|
|
'actors-editor': { min: 72, max: 180 },
|
|
'usecases-editor': { min: 72, max: 200 },
|
|
'io-editor': { min: 88, max: 220 },
|
|
'scope-editor': { min: 72, max: 180 },
|
|
};
|
|
|
|
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', 'srs-text', 'actors-editor', 'usecases-editor', 'io-editor', 'scope-editor'].forEach((id) => {
|
|
$(id).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', updateSrsFileSelection);
|
|
configurePdfEngine();
|
|
updateSrsFileSelection();
|
|
$('workspace-collapse').addEventListener('click', () => setWorkbenchOpen(!state.ui.workbenchOpen));
|
|
document.querySelectorAll('[data-workspace-tab]').forEach((button) => {
|
|
button.addEventListener('click', () => setWorkbenchTab(button.dataset.workspaceTab, true));
|
|
});
|
|
|
|
setWorkbenchTab('srs');
|
|
setWorkbenchOpen(false);
|
|
resizeAllTextareas();
|
|
loadDrafts();
|
|
}
|
|
|
|
function configurePdfEngine() {
|
|
if (window.pdfjsLib?.GlobalWorkerOptions) {
|
|
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.js';
|
|
}
|
|
}
|
|
|
|
function getSelectedSrsFile() {
|
|
return $('srs-file').files?.[0] || null;
|
|
}
|
|
|
|
function isPdfFile(file) {
|
|
return Boolean(file) && (/\.pdf$/i.test(file.name || '') || (file.type || '').toLowerCase() === 'application/pdf');
|
|
}
|
|
|
|
function updateSrsFileSelection(statusText = '') {
|
|
const file = getSelectedSrsFile();
|
|
const fileName = $('srs-file-name');
|
|
const note = $('srs-source-note');
|
|
|
|
if (statusText) {
|
|
fileName.textContent = statusText;
|
|
} else if (file) {
|
|
fileName.textContent = `الملف المختار: ${file.name}${isPdfFile(file) ? ' — PDF' : ''}`;
|
|
} else {
|
|
fileName.textContent = 'لم يتم اختيار ملف بعد.';
|
|
}
|
|
|
|
if (note) {
|
|
note.textContent = file && isPdfFile(file)
|
|
? 'سيتم استخراج النص من ملف PDF قبل التحليل. إذا كان الملف صورة ممسوحة ضوئياً فقد تحتاج إلى OCR أو لصق النص يدوياً.'
|
|
: 'يدعم TXT / MD / CSV / JSON / SRS / REQ إضافةً إلى PDF النصي. يمكنك أيضاً لصق المتطلبات مباشرة.';
|
|
}
|
|
}
|
|
|
|
function setSrsProcessingState(isBusy, statusText = '') {
|
|
const button = $('analyze-srs-btn');
|
|
button.disabled = Boolean(isBusy);
|
|
button.textContent = isBusy ? 'جارٍ تحليل المستند...' : 'حلّل الملف/النص';
|
|
updateSrsFileSelection(statusText);
|
|
}
|
|
|
|
async function readSrsSource({ file, manualText }) {
|
|
if (file) {
|
|
if (isPdfFile(file)) {
|
|
updateSrsFileSelection(`جارٍ استخراج النص من PDF: ${file.name}...`);
|
|
const content = await extractPdfText(file, ({ currentPage, totalPages }) => {
|
|
updateSrsFileSelection(`جارٍ استخراج النص من PDF: الصفحة ${currentPage}/${totalPages} — ${file.name}`);
|
|
});
|
|
if (!content.trim()) {
|
|
throw new Error('تعذر استخراج نص واضح من ملف PDF. إذا كان الملف صورة ممسوحة ضوئياً فاستخدم OCR أو الصق النص يدوياً.');
|
|
}
|
|
return content;
|
|
}
|
|
|
|
const content = await file.text();
|
|
if (looksBinary(content)) {
|
|
throw new Error('الملف المختار غير نصي مقروء. استخدم PDF نصي أو ملف TXT/MD/CSV/JSON، أو الصق النص يدوياً.');
|
|
}
|
|
return content;
|
|
}
|
|
|
|
return String(manualText || '').trim();
|
|
}
|
|
|
|
async function extractPdfText(file, onProgress) {
|
|
const pdfjs = window.pdfjsLib;
|
|
if (!pdfjs?.getDocument) {
|
|
throw new Error('محرّك قراءة PDF غير جاهز حالياً. حدّث الصفحة ثم جرّب مرة أخرى.');
|
|
}
|
|
|
|
pdfjs.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.js';
|
|
const data = await file.arrayBuffer();
|
|
const pdf = await pdfjs.getDocument({ data }).promise;
|
|
const pages = [];
|
|
|
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
|
if (typeof onProgress === 'function') {
|
|
onProgress({ currentPage: pageNumber, totalPages: pdf.numPages });
|
|
}
|
|
|
|
const page = await pdf.getPage(pageNumber);
|
|
const textContent = await page.getTextContent();
|
|
const pageText = extractPdfPageText(textContent);
|
|
if (pageText) pages.push(pageText);
|
|
}
|
|
|
|
return pages.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
function extractPdfPageText(textContent) {
|
|
const lines = [];
|
|
let currentLine = [];
|
|
let lastY = null;
|
|
|
|
const flushLine = () => {
|
|
const line = currentLine.join(' ').replace(/\s+/g, ' ').trim();
|
|
if (line) lines.push(line);
|
|
currentLine = [];
|
|
};
|
|
|
|
(textContent?.items || []).forEach((item) => {
|
|
const raw = String(item?.str ?? '').replace(/\s+/g, ' ').trim();
|
|
const y = Number(item?.transform?.[5] ?? lastY ?? 0);
|
|
|
|
if (lastY !== null && Math.abs(y - lastY) > 4) {
|
|
flushLine();
|
|
}
|
|
|
|
if (raw) {
|
|
currentLine.push(raw);
|
|
lastY = y;
|
|
}
|
|
|
|
if (item?.hasEOL) {
|
|
flushLine();
|
|
lastY = null;
|
|
}
|
|
});
|
|
|
|
flushLine();
|
|
return lines.join('\n').trim();
|
|
}
|
|
|
|
function autoGrow(event) {
|
|
resizeTextarea(event.target);
|
|
}
|
|
|
|
function resizeTextarea(target) {
|
|
if (!target || target.tagName !== 'TEXTAREA') return;
|
|
const limits = textareaLimits[target.id] || { min: 72, max: 180 };
|
|
|
|
if (target.offsetParent === null) {
|
|
target.style.height = `${limits.min}px`;
|
|
target.style.overflowY = 'hidden';
|
|
return;
|
|
}
|
|
|
|
target.style.height = '0px';
|
|
const nextHeight = Math.max(limits.min, Math.min(target.scrollHeight, limits.max));
|
|
target.style.height = `${nextHeight}px`;
|
|
target.style.overflowY = target.scrollHeight > limits.max ? 'auto' : 'hidden';
|
|
}
|
|
|
|
function resizeAllTextareas() {
|
|
document.querySelectorAll('textarea').forEach((textarea) => resizeTextarea(textarea));
|
|
}
|
|
|
|
function setWorkbenchOpen(open, options = {}) {
|
|
const { activeTab, focusChat = false } = options;
|
|
if (activeTab) setWorkbenchTab(activeTab);
|
|
|
|
state.ui.workbenchOpen = Boolean(open);
|
|
const body = $('workspace-body');
|
|
const panel = $('workspace-panel');
|
|
const toggle = $('workspace-collapse');
|
|
const toggleText = $('workspace-collapse-text');
|
|
const stateBadge = $('workspace-state');
|
|
|
|
panel.classList.toggle('collapsed', !state.ui.workbenchOpen);
|
|
body.classList.toggle('hidden', !state.ui.workbenchOpen);
|
|
toggle.setAttribute('aria-expanded', state.ui.workbenchOpen ? 'true' : 'false');
|
|
toggleText.textContent = state.ui.workbenchOpen ? 'إخفاء اللوحة' : 'فتح اللوحة';
|
|
stateBadge.textContent = state.ui.workbenchOpen ? 'مفتوحة' : 'مطوية';
|
|
|
|
updateWorkbenchSummary(state.current);
|
|
|
|
if (state.ui.workbenchOpen) {
|
|
requestAnimationFrame(() => {
|
|
resizeAllTextareas();
|
|
focusWorkbenchField();
|
|
});
|
|
} else if (focusChat) {
|
|
requestAnimationFrame(() => $('input').focus());
|
|
}
|
|
}
|
|
|
|
function setWorkbenchTab(tab, openIfCollapsed = false) {
|
|
state.ui.activeTab = tab === 'proposal' ? 'proposal' : 'srs';
|
|
|
|
document.querySelectorAll('[data-workspace-tab]').forEach((button) => {
|
|
const active = button.dataset.workspaceTab === state.ui.activeTab;
|
|
button.classList.toggle('active', active);
|
|
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
});
|
|
|
|
document.querySelectorAll('.workspace-card').forEach((panel) => {
|
|
const active = panel.dataset.panel === state.ui.activeTab;
|
|
panel.classList.toggle('active', active);
|
|
panel.hidden = !active;
|
|
});
|
|
|
|
if (openIfCollapsed && !state.ui.workbenchOpen) {
|
|
setWorkbenchOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (state.ui.workbenchOpen) {
|
|
requestAnimationFrame(() => {
|
|
resizeAllTextareas();
|
|
focusWorkbenchField();
|
|
});
|
|
}
|
|
}
|
|
|
|
function focusWorkbenchField() {
|
|
const targetId = state.ui.activeTab === 'proposal' ? 'actors-editor' : 'srs-text';
|
|
$(targetId)?.focus();
|
|
}
|
|
|
|
function updateWorkbenchSummary(data = state.current) {
|
|
const summaryEl = $('workspace-summary');
|
|
if (!summaryEl) return;
|
|
|
|
if (!data) {
|
|
summaryEl.textContent = 'ارفع SRS أو راجع الاقتراح من هنا بدون أن تزاحم مساحة الشات.';
|
|
return;
|
|
}
|
|
|
|
const source = data.srsDraft || data;
|
|
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,
|
|
};
|
|
|
|
const parts = [];
|
|
parts.push(`المرحلة: ${stageLabels[data.stage] || data.stage || '—'}`);
|
|
|
|
if (counts.actors || counts.useCases || counts.inputs || counts.outputs) {
|
|
parts.push(`${counts.actors || 0} Actors · ${counts.useCases || 0} Use Cases · ${counts.inputs || 0} Inputs · ${counts.outputs || 0} Outputs`);
|
|
} else {
|
|
parts.push('ابدأ برفع SRS أو لصق المتطلبات لتوليد الاقتراح.');
|
|
}
|
|
|
|
parts.push(data.needsStructureConfirmation ? 'الاقتراح جاهز للمراجعة والاعتماد.' : 'يمكنك إبقاء اللوحة مطوية والتركيز على المحادثة.');
|
|
summaryEl.textContent = parts.join(' — ');
|
|
}
|
|
|
|
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);
|
|
setWorkbenchTab('srs');
|
|
setWorkbenchOpen(false);
|
|
addAgentMessage(data.reply || 'تم بدء الجلسة.', true);
|
|
updateState(data);
|
|
await loadDrafts();
|
|
resizeAllTextareas();
|
|
$('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);
|
|
if (data.needsStructureConfirmation) {
|
|
setWorkbenchOpen(true, { activeTab: 'proposal' });
|
|
} else {
|
|
setWorkbenchTab('srs');
|
|
setWorkbenchOpen(false);
|
|
$('input').focus();
|
|
}
|
|
showResumeBanner(data.resumeMessage || 'أهلاً بك مجدداً، يمكننا متابعة العمل من آخر نقطة توقفنا عندها.');
|
|
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 = '';
|
|
resizeTextarea(input);
|
|
$('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 = getSelectedSrsFile();
|
|
const filename = file?.name || '';
|
|
let thinkingEl = null;
|
|
|
|
try {
|
|
setSrsProcessingState(true);
|
|
const content = await readSrsSource({ file, manualText: $('srs-text').value.trim() });
|
|
|
|
if (!content.trim()) {
|
|
throw new Error('أرفق ملفاً أو الصق نص المتطلبات أولاً.');
|
|
}
|
|
|
|
if (looksBinary(content)) {
|
|
throw new Error('المحتوى المقروء غير صالح للتحليل كنص. استخدم PDF نصي أو ألصق النص مباشرة.');
|
|
}
|
|
|
|
addUserMessage(filename ? `📄 تم إرسال ملف SRS للتحليل: ${filename}` : '📄 تم إرسال نص SRS للتحليل');
|
|
thinkingEl = addAgentMessage('أحلّل المستند وأستخرج Actors وUse Cases وInputs/Outputs...', false, true);
|
|
|
|
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();
|
|
thinkingEl = null;
|
|
addAgentMessage(data.reply || 'تم تحليل المستند.', true);
|
|
updateState(data);
|
|
setWorkbenchTab('proposal');
|
|
setWorkbenchOpen(true);
|
|
await loadDrafts();
|
|
} catch (error) {
|
|
if (thinkingEl) {
|
|
thinkingEl.querySelector('.bubble').textContent = `⚠️ ${error.message}`;
|
|
thinkingEl.classList.remove('thinking');
|
|
} else {
|
|
alert(`خطأ: ${error.message}`);
|
|
}
|
|
} finally {
|
|
setSrsProcessingState(false);
|
|
}
|
|
}
|
|
|
|
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();
|
|
setWorkbenchOpen(false, { focusChat: true });
|
|
} 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);
|
|
updateWorkbenchSummary(data);
|
|
resizeAllTextareas();
|
|
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
}
|