feat(wevia-public-premium): 4 generators DOCX/XLSX/PPTX/REACT premium qualite + auto-intent router JS + preview panel wiring - ambre-tool-docx.php python-docx 1.2 (Synthese Executive box, tables, bullets, styles indigo) - ambre-tool-xlsx.php openpyxl 3.1 (headers stylés primary, totals auto, auto-filter, freeze panes) - ambre-tool-pptx.php python-pptx 1.0 (10 slides 16:9 types title/content/two_column/stats/table/conclusion, gradients, cards 54pt) - ambre-tool-react.php React18+Tailwind+Babel standalone HTML - wevia-gen-router.js detects intent from NL message, triggers API, banner progress/download, opens preview iframe Google Docs Viewer pour Office - prompt restrictif no confidential WEVAL info divulgue - chattr +i restored sur wevia.html
This commit is contained in:
179
api/ambre-tool-docx-render.py
Normal file
179
api/ambre-tool-docx-render.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ambre-tool-docx-render.py — Render JSON to premium docx
|
||||
Usage: python3 ambre-tool-docx-render.py <input.json> <output.docx>
|
||||
"""
|
||||
import sys, json
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Inches, Cm
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.table import WD_ALIGN_VERTICAL
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
from datetime import datetime
|
||||
|
||||
def add_border(cell, color="4f46e5"):
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
borders = OxmlElement('w:tcBorders')
|
||||
for side in ('top','left','bottom','right'):
|
||||
b = OxmlElement(f'w:{side}')
|
||||
b.set(qn('w:val'), 'single')
|
||||
b.set(qn('w:sz'), '4')
|
||||
b.set(qn('w:color'), color)
|
||||
borders.append(b)
|
||||
tc_pr.append(borders)
|
||||
|
||||
def shade_cell(cell, color):
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
shd = OxmlElement('w:shd')
|
||||
shd.set(qn('w:val'), 'clear')
|
||||
shd.set(qn('w:color'), 'auto')
|
||||
shd.set(qn('w:fill'), color)
|
||||
tc_pr.append(shd)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: render <input.json> <output.docx>"); sys.exit(1)
|
||||
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
doc_data = json.load(f)
|
||||
|
||||
doc = Document()
|
||||
|
||||
# Page setup
|
||||
for section in doc.sections:
|
||||
section.top_margin = Cm(2.2)
|
||||
section.bottom_margin = Cm(2.2)
|
||||
section.left_margin = Cm(2.5)
|
||||
section.right_margin = Cm(2.5)
|
||||
|
||||
# Style base font
|
||||
style = doc.styles['Normal']
|
||||
style.font.name = 'Calibri'
|
||||
style.font.size = Pt(11)
|
||||
|
||||
# Title
|
||||
title_p = doc.add_paragraph()
|
||||
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
title_r = title_p.add_run(doc_data.get('title', 'Document'))
|
||||
title_r.font.size = Pt(28)
|
||||
title_r.font.bold = True
|
||||
title_r.font.color.rgb = RGBColor(0x1e, 0x3a, 0x8a) # deep blue
|
||||
|
||||
# Subtitle
|
||||
if doc_data.get('subtitle'):
|
||||
sub_p = doc.add_paragraph()
|
||||
sub_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
sub_r = sub_p.add_run(doc_data['subtitle'])
|
||||
sub_r.font.size = Pt(14)
|
||||
sub_r.font.italic = True
|
||||
sub_r.font.color.rgb = RGBColor(0x64, 0x74, 0x8b)
|
||||
|
||||
# Author + date
|
||||
meta_p = doc.add_paragraph()
|
||||
meta_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
meta_r = meta_p.add_run(f"{doc_data.get('author', 'WEVAL Consulting')} | {datetime.now().strftime('%d %B %Y')}")
|
||||
meta_r.font.size = Pt(10)
|
||||
meta_r.font.color.rgb = RGBColor(0x94, 0xa3, 0xb8)
|
||||
|
||||
doc.add_paragraph() # spacer
|
||||
|
||||
# Executive Summary with box
|
||||
if doc_data.get('executive_summary'):
|
||||
exec_h = doc.add_heading('Synthese Executive', level=1)
|
||||
for run in exec_h.runs:
|
||||
run.font.color.rgb = RGBColor(0x4f, 0x46, 0xe5)
|
||||
|
||||
# Put exec summary in a 1-cell table for box style
|
||||
t = doc.add_table(rows=1, cols=1)
|
||||
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
cell = t.cell(0, 0)
|
||||
shade_cell(cell, 'f0f4ff')
|
||||
add_border(cell, '4f46e5')
|
||||
cell_p = cell.paragraphs[0]
|
||||
cell_r = cell_p.add_run(doc_data['executive_summary'])
|
||||
cell_r.font.size = Pt(11)
|
||||
cell_r.font.italic = True
|
||||
doc.add_paragraph()
|
||||
|
||||
# Sections
|
||||
for section_data in doc_data.get('sections', []):
|
||||
# Heading
|
||||
h = doc.add_heading(section_data.get('heading', 'Section'), level=1)
|
||||
for run in h.runs:
|
||||
run.font.color.rgb = RGBColor(0x4f, 0x46, 0xe5)
|
||||
run.font.size = Pt(18)
|
||||
|
||||
# Paragraphs
|
||||
for para in section_data.get('paragraphs', []):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(8)
|
||||
p.paragraph_format.line_spacing = 1.4
|
||||
r = p.add_run(para)
|
||||
r.font.size = Pt(11)
|
||||
|
||||
# Bullets
|
||||
bullets = section_data.get('bullets', [])
|
||||
if bullets:
|
||||
for b in bullets:
|
||||
bp = doc.add_paragraph(b, style='List Bullet')
|
||||
bp.paragraph_format.space_after = Pt(4)
|
||||
|
||||
# Table
|
||||
table_data = section_data.get('table')
|
||||
if table_data and table_data.get('headers') and table_data.get('rows'):
|
||||
headers = table_data['headers']
|
||||
rows = table_data['rows']
|
||||
|
||||
t = doc.add_table(rows=1+len(rows), cols=len(headers))
|
||||
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
# Header row
|
||||
for i, h_text in enumerate(headers):
|
||||
cell = t.cell(0, i)
|
||||
shade_cell(cell, '4f46e5')
|
||||
add_border(cell, '4f46e5')
|
||||
cell_p = cell.paragraphs[0]
|
||||
cell_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = cell_p.add_run(str(h_text))
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = RGBColor(0xff, 0xff, 0xff)
|
||||
run.font.size = Pt(11)
|
||||
|
||||
# Data rows
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, val in enumerate(row[:len(headers)]):
|
||||
cell = t.cell(r_idx+1, c_idx)
|
||||
add_border(cell, 'cbd5e1')
|
||||
if r_idx % 2 == 0:
|
||||
shade_cell(cell, 'f8fafc')
|
||||
cell_p = cell.paragraphs[0]
|
||||
run = cell_p.add_run(str(val))
|
||||
run.font.size = Pt(10)
|
||||
|
||||
doc.add_paragraph()
|
||||
|
||||
# Conclusion
|
||||
if doc_data.get('conclusion'):
|
||||
h = doc.add_heading('Conclusion', level=1)
|
||||
for run in h.runs:
|
||||
run.font.color.rgb = RGBColor(0x4f, 0x46, 0xe5)
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.line_spacing = 1.4
|
||||
r = p.add_run(doc_data['conclusion'])
|
||||
r.font.size = Pt(11)
|
||||
|
||||
# Footer
|
||||
doc.add_paragraph()
|
||||
footer_p = doc.add_paragraph()
|
||||
footer_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
footer_r = footer_p.add_run(f"Document genere par WEVAL Consulting - {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
footer_r.font.size = Pt(8)
|
||||
footer_r.font.italic = True
|
||||
footer_r.font.color.rgb = RGBColor(0x94, 0xa3, 0xb8)
|
||||
|
||||
doc.save(sys.argv[2])
|
||||
print(f"OK: {sys.argv[2]}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
124
api/ambre-tool-docx.php
Normal file
124
api/ambre-tool-docx.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* ambre-tool-docx.php — Premium Word document generation
|
||||
* Input: JSON {topic: "..."}
|
||||
* Output: JSON {ok:true, url:"/files/xxx.docx", title, sections, size}
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Call sovereign LLM cascade to generate structured JSON content
|
||||
* 2. Python python-docx renders professional .docx with heading styles, TOC, tables
|
||||
* 3. Upload to /files/ returns public URL
|
||||
*/
|
||||
header('Content-Type: application/json');
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false, 'error'=>'POST only']); exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$topic = trim($input['topic'] ?? '');
|
||||
if (strlen($topic) < 3) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'topic too short']); exit;
|
||||
}
|
||||
$topic = substr($topic, 0, 500);
|
||||
|
||||
// Step 1: Generate content via sovereign LLM
|
||||
$prompt = "Genere un document Word professionnel structure sur: \"$topic\"\n\n"
|
||||
. "Retourne UNIQUEMENT du JSON valide (sans markdown code fence) avec:\n"
|
||||
. "{\n"
|
||||
. " \"title\": \"Titre principal\",\n"
|
||||
. " \"subtitle\": \"Sous-titre\",\n"
|
||||
. " \"author\": \"WEVAL Consulting\",\n"
|
||||
. " \"executive_summary\": \"Paragraphe de synthese de 4-6 phrases\",\n"
|
||||
. " \"sections\": [\n"
|
||||
. " {\n"
|
||||
. " \"heading\": \"1. Titre section\",\n"
|
||||
. " \"paragraphs\": [\"Paragraphe 1...\", \"Paragraphe 2...\"],\n"
|
||||
. " \"bullets\": [\"Point cle 1\", \"Point cle 2\"],\n"
|
||||
. " \"table\": {\"headers\":[\"Col1\",\"Col2\"], \"rows\":[[\"v1\",\"v2\"]]}\n"
|
||||
. " }\n"
|
||||
. " ],\n"
|
||||
. " \"conclusion\": \"Paragraphe de conclusion\"\n"
|
||||
. "}\n\n"
|
||||
. "IMPORTANT:\n"
|
||||
. "- 5 a 7 sections completes\n"
|
||||
. "- Chaque section a 2-3 paragraphes detailes (60-120 mots chacun)\n"
|
||||
. "- 3-5 bullets par section quand pertinent\n"
|
||||
. "- Ajouter une table dans au moins 2 sections\n"
|
||||
. "- Francais professionnel sans accents probematiques\n"
|
||||
. "- Pas d'info confidentielle WEVAL, generique et factuelle\n"
|
||||
. "- JSON valide uniquement, aucun texte avant ou apres";
|
||||
|
||||
// Use sovereign cascade
|
||||
$ch = curl_init('http://127.0.0.1:4000/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => 'auto',
|
||||
'messages' => [['role'=>'user', 'content'=>$prompt]],
|
||||
'max_tokens' => 4000,
|
||||
'temperature' => 0.7
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 90,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http !== 200) {
|
||||
echo json_encode(['ok'=>false, 'error'=>"LLM HTTP $http"]); exit;
|
||||
}
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$content_raw = $data['choices'][0]['message']['content'] ?? '';
|
||||
// Extract JSON from markdown fences if any
|
||||
/* BALANCED_JSON_V2 */
|
||||
if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $content_raw, $m)) {
|
||||
$content_raw = $m[1];
|
||||
}
|
||||
$_jstart = strpos($content_raw, '{');
|
||||
if ($_jstart !== false) {
|
||||
$_depth = 0; $_jend = -1;
|
||||
for ($_i = $_jstart; $_i < strlen($content_raw); $_i++) {
|
||||
if ($content_raw[$_i] === '{') $_depth++;
|
||||
elseif ($content_raw[$_i] === '}') { $_depth--; if ($_depth === 0) { $_jend = $_i; break; } }
|
||||
}
|
||||
if ($_jend > $_jstart) $content_raw = substr($content_raw, $_jstart, $_jend - $_jstart + 1);
|
||||
}
|
||||
$doc = json_decode($content_raw, true);
|
||||
if (!$doc || !isset($doc['title'])) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'LLM returned invalid JSON', 'raw'=>substr($content_raw,0,500)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Step 2: Python docx generation
|
||||
$tmpjson = tempnam('/tmp', 'docx_') . '.json';
|
||||
file_put_contents($tmpjson, json_encode($doc));
|
||||
|
||||
$filename = 'weval-' . substr(md5($topic . microtime(true)), 0, 10) . '.docx';
|
||||
$outpath = '/var/www/html/files/' . $filename;
|
||||
if (!is_dir('/var/www/html/files')) { mkdir('/var/www/html/files', 0755, true); }
|
||||
|
||||
$pyScript = '/var/www/html/api/ambre-tool-docx-render.py';
|
||||
|
||||
$cmd = "python3 " . escapeshellarg($pyScript) . " " . escapeshellarg($tmpjson) . " " . escapeshellarg($outpath) . " 2>&1";
|
||||
$out = shell_exec($cmd);
|
||||
@unlink($tmpjson);
|
||||
|
||||
if (!file_exists($outpath)) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'docx render failed', 'py_out'=>substr($out, 0, 500)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$size = filesize($outpath);
|
||||
$n_sections = count($doc['sections'] ?? []);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'url' => '/files/' . $filename,
|
||||
'title' => $doc['title'],
|
||||
'sections' => $n_sections,
|
||||
'size' => $size,
|
||||
'size_kb' => round($size/1024, 1),
|
||||
]);
|
||||
258
api/ambre-tool-pptx-render.py
Normal file
258
api/ambre-tool-pptx-render.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ambre-tool-pptx-render.py — Premium PowerPoint from JSON"""
|
||||
import sys, json
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from datetime import datetime
|
||||
|
||||
WIDTH, HEIGHT = Inches(13.333), Inches(7.5) # 16:9
|
||||
PRIMARY = RGBColor(0x4f, 0x46, 0xe5)
|
||||
ACCENT = RGBColor(0x10, 0xb9, 0x81)
|
||||
DARK = RGBColor(0x0f, 0x17, 0x2a)
|
||||
LIGHT = RGBColor(0xf8, 0xfa, 0xfc)
|
||||
GRAY = RGBColor(0x64, 0x74, 0x8b)
|
||||
|
||||
def gradient_bg(slide, c1=DARK, c2=PRIMARY):
|
||||
"""Add full-slide gradient rect as background"""
|
||||
left = top = 0
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, HEIGHT)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = c1
|
||||
shape.line.fill.background()
|
||||
# Lower to back
|
||||
spTree = shape._element.getparent()
|
||||
spTree.remove(shape._element)
|
||||
spTree.insert(2, shape._element)
|
||||
return shape
|
||||
|
||||
def add_text(slide, text, left, top, width, height, size=18, bold=False, color=DARK, align=PP_ALIGN.LEFT):
|
||||
tb = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = tb.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = Emu(0); tf.margin_right = Emu(0)
|
||||
tf.margin_top = Emu(0); tf.margin_bottom = Emu(0)
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = align
|
||||
r = p.add_run()
|
||||
r.text = text
|
||||
r.font.size = Pt(size)
|
||||
r.font.bold = bold
|
||||
r.font.color.rgb = color
|
||||
r.font.name = 'Calibri'
|
||||
return tb
|
||||
|
||||
def add_title_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||
gradient_bg(slide, DARK, PRIMARY)
|
||||
|
||||
# Title
|
||||
add_text(slide, data.get('title', 'Titre'), Inches(1), Inches(2.5), Inches(11.3), Inches(1.5),
|
||||
size=48, bold=True, color=RGBColor(0xff,0xff,0xff), align=PP_ALIGN.CENTER)
|
||||
|
||||
# Subtitle
|
||||
if data.get('subtitle'):
|
||||
add_text(slide, data['subtitle'], Inches(1), Inches(4.2), Inches(11.3), Inches(1),
|
||||
size=22, color=RGBColor(0xcb, 0xd5, 0xe1), align=PP_ALIGN.CENTER)
|
||||
|
||||
# Author footer
|
||||
add_text(slide, data.get('author','WEVAL Consulting') + ' - ' + datetime.now().strftime('%d %B %Y'),
|
||||
Inches(1), Inches(6.8), Inches(11.3), Inches(0.5),
|
||||
size=12, color=RGBColor(0x94,0xa3,0xb8), align=PP_ALIGN.CENTER)
|
||||
|
||||
def add_content_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
# Light bg
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, HEIGHT)
|
||||
bg.fill.solid(); bg.fill.fore_color.rgb = LIGHT; bg.line.fill.background()
|
||||
spTree = bg._element.getparent(); spTree.remove(bg._element); spTree.insert(2, bg._element)
|
||||
|
||||
# Top accent bar
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, Inches(0.4))
|
||||
bar.fill.solid(); bar.fill.fore_color.rgb = PRIMARY; bar.line.fill.background()
|
||||
|
||||
# Title
|
||||
add_text(slide, data.get('title','Section'), Inches(0.6), Inches(0.7), Inches(12), Inches(0.9),
|
||||
size=32, bold=True, color=DARK)
|
||||
|
||||
# Bullets
|
||||
bullets = data.get('bullets', [])
|
||||
if bullets:
|
||||
tb = slide.shapes.add_textbox(Inches(0.8), Inches(1.9), Inches(12), Inches(5))
|
||||
tf = tb.text_frame; tf.word_wrap = True
|
||||
for i, b in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.level = 0
|
||||
r = p.add_run()
|
||||
r.text = ' ' + str(b)
|
||||
r.font.size = Pt(20)
|
||||
r.font.color.rgb = DARK
|
||||
r.font.name = 'Calibri'
|
||||
p.space_after = Pt(14)
|
||||
|
||||
# Footer
|
||||
add_text(slide, data.get('author','WEVAL'), Inches(0.6), Inches(7.0), Inches(12), Inches(0.3),
|
||||
size=10, color=GRAY)
|
||||
|
||||
def add_two_column_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, HEIGHT)
|
||||
bg.fill.solid(); bg.fill.fore_color.rgb = LIGHT; bg.line.fill.background()
|
||||
spTree = bg._element.getparent(); spTree.remove(bg._element); spTree.insert(2, bg._element)
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, Inches(0.4))
|
||||
bar.fill.solid(); bar.fill.fore_color.rgb = PRIMARY; bar.line.fill.background()
|
||||
|
||||
add_text(slide, data.get('title','Comparaison'), Inches(0.6), Inches(0.7), Inches(12), Inches(0.9),
|
||||
size=30, bold=True, color=DARK)
|
||||
|
||||
# Left column
|
||||
left_data = data.get('left', {})
|
||||
left_box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.6), Inches(2), Inches(5.8), Inches(4.8))
|
||||
left_box.fill.solid(); left_box.fill.fore_color.rgb = RGBColor(0xe0, 0xe7, 0xff)
|
||||
left_box.line.color.rgb = PRIMARY; left_box.line.width = Pt(2)
|
||||
|
||||
add_text(slide, left_data.get('heading','Gauche'), Inches(0.8), Inches(2.2), Inches(5.4), Inches(0.6),
|
||||
size=20, bold=True, color=PRIMARY)
|
||||
|
||||
items = left_data.get('items', [])
|
||||
if items:
|
||||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(3), Inches(5.2), Inches(3.5))
|
||||
tf = tb.text_frame; tf.word_wrap = True
|
||||
for i, it in enumerate(items):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
r = p.add_run(); r.text = ' ' + str(it); r.font.size = Pt(15); r.font.color.rgb = DARK
|
||||
p.space_after = Pt(8)
|
||||
|
||||
# Right column
|
||||
right_data = data.get('right', {})
|
||||
right_box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(6.9), Inches(2), Inches(5.8), Inches(4.8))
|
||||
right_box.fill.solid(); right_box.fill.fore_color.rgb = RGBColor(0xd1, 0xfa, 0xe5)
|
||||
right_box.line.color.rgb = ACCENT; right_box.line.width = Pt(2)
|
||||
|
||||
add_text(slide, right_data.get('heading','Droite'), Inches(7.1), Inches(2.2), Inches(5.4), Inches(0.6),
|
||||
size=20, bold=True, color=ACCENT)
|
||||
|
||||
items2 = right_data.get('items', [])
|
||||
if items2:
|
||||
tb = slide.shapes.add_textbox(Inches(7.2), Inches(3), Inches(5.2), Inches(3.5))
|
||||
tf = tb.text_frame; tf.word_wrap = True
|
||||
for i, it in enumerate(items2):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
r = p.add_run(); r.text = ' ' + str(it); r.font.size = Pt(15); r.font.color.rgb = DARK
|
||||
p.space_after = Pt(8)
|
||||
|
||||
def add_stats_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, HEIGHT)
|
||||
bg.fill.solid(); bg.fill.fore_color.rgb = DARK; bg.line.fill.background()
|
||||
spTree = bg._element.getparent(); spTree.remove(bg._element); spTree.insert(2, bg._element)
|
||||
|
||||
add_text(slide, data.get('title','Chiffres cles'), Inches(0.6), Inches(0.7), Inches(12), Inches(0.9),
|
||||
size=32, bold=True, color=RGBColor(0xff,0xff,0xff), align=PP_ALIGN.CENTER)
|
||||
|
||||
stats = data.get('stats', [])[:4]
|
||||
if stats:
|
||||
n = len(stats)
|
||||
card_w = (WIDTH - Inches(1.2) - Inches(0.4)*(n-1)) / n if n else Inches(3)
|
||||
for i, s in enumerate(stats):
|
||||
x = Inches(0.6) + (card_w + Inches(0.4)) * i
|
||||
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, Inches(2.3), card_w, Inches(3.5))
|
||||
card.fill.solid(); card.fill.fore_color.rgb = PRIMARY
|
||||
card.line.fill.background()
|
||||
|
||||
# Value
|
||||
tb1 = slide.shapes.add_textbox(x, Inches(2.8), card_w, Inches(1.8))
|
||||
tf1 = tb1.text_frame; tf1.word_wrap = True
|
||||
p1 = tf1.paragraphs[0]; p1.alignment = PP_ALIGN.CENTER
|
||||
r1 = p1.add_run(); r1.text = str(s.get('value',''))
|
||||
r1.font.size = Pt(54); r1.font.bold = True; r1.font.color.rgb = RGBColor(0xff,0xff,0xff)
|
||||
|
||||
# Label
|
||||
tb2 = slide.shapes.add_textbox(x, Inches(4.6), card_w, Inches(1))
|
||||
tf2 = tb2.text_frame; tf2.word_wrap = True
|
||||
p2 = tf2.paragraphs[0]; p2.alignment = PP_ALIGN.CENTER
|
||||
r2 = p2.add_run(); r2.text = str(s.get('label',''))
|
||||
r2.font.size = Pt(14); r2.font.color.rgb = RGBColor(0xcb,0xd5,0xe1)
|
||||
|
||||
def add_table_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, HEIGHT)
|
||||
bg.fill.solid(); bg.fill.fore_color.rgb = LIGHT; bg.line.fill.background()
|
||||
spTree = bg._element.getparent(); spTree.remove(bg._element); spTree.insert(2, bg._element)
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, WIDTH, Inches(0.4))
|
||||
bar.fill.solid(); bar.fill.fore_color.rgb = PRIMARY; bar.line.fill.background()
|
||||
|
||||
add_text(slide, data.get('title','Tableau'), Inches(0.6), Inches(0.7), Inches(12), Inches(0.9),
|
||||
size=30, bold=True, color=DARK)
|
||||
|
||||
headers = data.get('headers', [])
|
||||
rows = data.get('rows', [])
|
||||
if headers and rows:
|
||||
n_cols = len(headers)
|
||||
n_rows = len(rows) + 1
|
||||
tbl_shape = slide.shapes.add_table(n_rows, n_cols, Inches(0.6), Inches(2), Inches(12), Inches(5))
|
||||
tbl = tbl_shape.table
|
||||
for i, h in enumerate(headers):
|
||||
cell = tbl.cell(0, i)
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = PRIMARY
|
||||
p = cell.text_frame.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
r = p.add_run(); r.text = str(h); r.font.size = Pt(14); r.font.bold = True
|
||||
r.font.color.rgb = RGBColor(0xff,0xff,0xff)
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, val in enumerate(row[:n_cols]):
|
||||
cell = tbl.cell(r_idx+1, c_idx)
|
||||
if r_idx % 2 == 0:
|
||||
cell.fill.solid(); cell.fill.fore_color.rgb = RGBColor(0xf8,0xfa,0xfc)
|
||||
p = cell.text_frame.paragraphs[0]
|
||||
r = p.add_run(); r.text = str(val); r.font.size = Pt(12); r.font.color.rgb = DARK
|
||||
|
||||
def add_conclusion_slide(prs, data):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
gradient_bg(slide, PRIMARY, DARK)
|
||||
|
||||
add_text(slide, data.get('title','Conclusion'), Inches(1), Inches(1.5), Inches(11.3), Inches(1),
|
||||
size=40, bold=True, color=RGBColor(0xff,0xff,0xff), align=PP_ALIGN.CENTER)
|
||||
|
||||
if data.get('text'):
|
||||
add_text(slide, data['text'], Inches(1.5), Inches(3), Inches(10.3), Inches(1.5),
|
||||
size=18, color=RGBColor(0xcb,0xd5,0xe1), align=PP_ALIGN.CENTER)
|
||||
|
||||
bullets = data.get('bullets', [])
|
||||
if bullets:
|
||||
tb = slide.shapes.add_textbox(Inches(2), Inches(4.8), Inches(9.3), Inches(2.2))
|
||||
tf = tb.text_frame; tf.word_wrap = True
|
||||
for i, b in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
r = p.add_run(); r.text = str(b); r.font.size = Pt(16); r.font.color.rgb = RGBColor(0xff,0xff,0xff)
|
||||
p.space_after = Pt(8)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3: sys.exit("Usage: render <input.json> <output.pptx>")
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
prs = Presentation()
|
||||
prs.slide_width = WIDTH
|
||||
prs.slide_height = HEIGHT
|
||||
|
||||
# Cover
|
||||
add_title_slide(prs, data)
|
||||
|
||||
for sl in data.get('slides', []):
|
||||
t = sl.get('type', 'content')
|
||||
if t == 'title': add_title_slide(prs, sl)
|
||||
elif t == 'two_column': add_two_column_slide(prs, sl)
|
||||
elif t == 'stats': add_stats_slide(prs, sl)
|
||||
elif t == 'table': add_table_slide(prs, sl)
|
||||
elif t == 'conclusion': add_conclusion_slide(prs, sl)
|
||||
else: add_content_slide(prs, sl)
|
||||
|
||||
prs.save(sys.argv[2])
|
||||
print(f"OK: {sys.argv[2]} ({len(prs.slides)} slides)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
102
api/ambre-tool-pptx.php
Normal file
102
api/ambre-tool-pptx.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* ambre-tool-pptx.php - Premium PowerPoint generation
|
||||
* Input: JSON {topic}
|
||||
* Output: JSON {ok, url, slides, title}
|
||||
*/
|
||||
header('Content-Type: application/json');
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false, 'error'=>'POST only']); exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$topic = trim($input['topic'] ?? '');
|
||||
if (strlen($topic) < 3) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'topic too short']); exit;
|
||||
}
|
||||
$topic = substr($topic, 0, 500);
|
||||
|
||||
$prompt = "Genere une presentation PowerPoint professionnelle sur: \"$topic\"\n\n"
|
||||
. "Retourne UNIQUEMENT du JSON valide:\n"
|
||||
. "{\n"
|
||||
. " \"title\": \"Titre principal\",\n"
|
||||
. " \"subtitle\": \"Sous-titre\",\n"
|
||||
. " \"author\": \"WEVAL Consulting\",\n"
|
||||
. " \"slides\": [\n"
|
||||
. " {\"type\":\"title\", \"title\":\"Titre\", \"subtitle\":\"...\"},\n"
|
||||
. " {\"type\":\"content\", \"title\":\"Section 1\", \"bullets\":[\"Point 1\", \"Point 2\"]},\n"
|
||||
. " {\"type\":\"two_column\", \"title\":\"Comparaison\", \"left\":{\"heading\":\"Avant\", \"items\":[...]}, \"right\":{\"heading\":\"Apres\", \"items\":[...]}},\n"
|
||||
. " {\"type\":\"stats\", \"title\":\"Chiffres cles\", \"stats\":[{\"value\":\"80%\", \"label\":\"Libelle\"}]},\n"
|
||||
. " {\"type\":\"table\", \"title\":\"Tableau\", \"headers\":[\"A\",\"B\"], \"rows\":[[\"v1\",\"v2\"]]},\n"
|
||||
. " {\"type\":\"conclusion\", \"title\":\"Conclusion\", \"text\":\"...\", \"bullets\":[...]}\n"
|
||||
. " ]\n"
|
||||
. "}\n\n"
|
||||
. "IMPORTANT:\n"
|
||||
. "- 8 a 12 slides au total (commencant par 'title' et finissant par 'conclusion')\n"
|
||||
. "- Varier les types: content / two_column / stats / table / content\n"
|
||||
. "- Bullets concis et percutants (10-15 mots chacun)\n"
|
||||
. "- Stats: 3-4 chiffres avec valeur + libelle court\n"
|
||||
. "- Francais pro, pas d'info confidentielle WEVAL\n"
|
||||
. "- JSON valide uniquement";
|
||||
|
||||
$ch = curl_init('http://127.0.0.1:4000/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => 'auto',
|
||||
'messages' => [['role'=>'user', 'content'=>$prompt]],
|
||||
'max_tokens' => 4500, 'temperature' => 0.7
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 90,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http !== 200) { echo json_encode(['ok'=>false, 'error'=>"LLM HTTP $http"]); exit; }
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$content_raw = $data['choices'][0]['message']['content'] ?? '';
|
||||
/* BALANCED_JSON_V2 */
|
||||
if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $content_raw, $m)) {
|
||||
$content_raw = $m[1];
|
||||
}
|
||||
$_jstart = strpos($content_raw, '{');
|
||||
if ($_jstart !== false) {
|
||||
$_depth = 0; $_jend = -1;
|
||||
for ($_i = $_jstart; $_i < strlen($content_raw); $_i++) {
|
||||
if ($content_raw[$_i] === '{') $_depth++;
|
||||
elseif ($content_raw[$_i] === '}') { $_depth--; if ($_depth === 0) { $_jend = $_i; break; } }
|
||||
}
|
||||
if ($_jend > $_jstart) $content_raw = substr($content_raw, $_jstart, $_jend - $_jstart + 1);
|
||||
}
|
||||
$deck = json_decode($content_raw, true);
|
||||
if (!$deck || !isset($deck['title'])) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'LLM invalid JSON', 'raw'=>substr($content_raw,0,500)]); exit;
|
||||
}
|
||||
|
||||
$tmpjson = tempnam('/tmp', 'pptx_') . '.json';
|
||||
file_put_contents($tmpjson, json_encode($deck));
|
||||
|
||||
$filename = 'weval-' . substr(md5($topic . microtime(true)), 0, 10) . '.pptx';
|
||||
$outpath = '/var/www/html/files/' . $filename;
|
||||
if (!is_dir('/var/www/html/files')) { mkdir('/var/www/html/files', 0755, true); }
|
||||
|
||||
$pyScript = '/var/www/html/api/ambre-tool-pptx-render.py';
|
||||
$cmd = "python3 " . escapeshellarg($pyScript) . " " . escapeshellarg($tmpjson) . " " . escapeshellarg($outpath) . " 2>&1";
|
||||
$out = shell_exec($cmd);
|
||||
@unlink($tmpjson);
|
||||
|
||||
if (!file_exists($outpath)) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'pptx render failed', 'py_out'=>substr($out, 0, 500)]); exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'url' => '/files/' . $filename,
|
||||
'title' => $deck['title'],
|
||||
'slides' => count($deck['slides'] ?? []),
|
||||
'size' => filesize($outpath),
|
||||
'size_kb' => round(filesize($outpath)/1024, 1),
|
||||
]);
|
||||
82
api/ambre-tool-react.php
Normal file
82
api/ambre-tool-react.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* ambre-tool-react.php - React component generator with live artifact preview
|
||||
* Input: JSON {topic}
|
||||
* Output: JSON {ok, preview_url, code, title}
|
||||
*/
|
||||
header('Content-Type: application/json');
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false, 'error'=>'POST only']); exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$topic = trim($input['topic'] ?? '');
|
||||
if (strlen($topic) < 3) { echo json_encode(['ok'=>false, 'error'=>'topic too short']); exit; }
|
||||
$topic = substr($topic, 0, 500);
|
||||
|
||||
$prompt = "Tu es un expert frontend React. Genere UN composant React autonome pour: \"$topic\"\n\n"
|
||||
. "Contraintes techniques:\n"
|
||||
. "- React 18 via CDN (pas d'imports externes npm)\n"
|
||||
. "- TailwindCSS via CDN (class utilities)\n"
|
||||
. "- Pas de Router, pas de state manager\n"
|
||||
. "- TOUT le code dans UN seul fichier HTML renderable directement\n"
|
||||
. "- Design ultra-premium: gradients, animations CSS, hover effects, responsive\n"
|
||||
. "- Palette moderne (indigo/slate/violet/emerald)\n"
|
||||
. "- Composant interactif avec au moins 1 etat useState\n"
|
||||
. "- Pas de alert() ni prompt(), UX seulement\n"
|
||||
. "- Icones via Unicode emojis ou SVG inline\n"
|
||||
. "- Si donnees: tableau/array inline dans le composant (pas fetch externe)\n\n"
|
||||
. "IMPORTANT:\n"
|
||||
. "- Retourne UNIQUEMENT le code HTML complet commencant par <!DOCTYPE html>\n"
|
||||
. "- Aucun texte d'explication avant ou apres\n"
|
||||
. "- Pas de backticks markdown\n"
|
||||
. "- Le code doit s'ouvrir directement dans un browser et fonctionner";
|
||||
|
||||
$ch = curl_init('http://127.0.0.1:4000/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => 'auto',
|
||||
'messages' => [['role'=>'user', 'content'=>$prompt]],
|
||||
'max_tokens' => 6000, 'temperature' => 0.7
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http !== 200) { echo json_encode(['ok'=>false, 'error'=>"LLM HTTP $http"]); exit; }
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$html = $data['choices'][0]['message']['content'] ?? '';
|
||||
|
||||
// Strip markdown code fences if any
|
||||
$html = preg_replace('/^```(?:html)?\s*\n/', '', $html);
|
||||
$html = preg_replace('/\n```\s*$/', '', $html);
|
||||
$html = trim($html);
|
||||
|
||||
// Validation: must contain DOCTYPE and a react/tailwind reference
|
||||
if (stripos($html, '<!DOCTYPE') === false) {
|
||||
// Wrap in minimal HTML shell if LLM just returned JSX
|
||||
$html = "<!DOCTYPE html>\n<html lang='fr'>\n<head>\n<meta charset='UTF-8'>\n<script src='https://cdn.tailwindcss.com'></script>\n<script crossorigin src='https://unpkg.com/react@18/umd/react.production.min.js'></script>\n<script crossorigin src='https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'></script>\n<script src='https://unpkg.com/@babel/standalone/babel.min.js'></script>\n</head>\n<body class='bg-slate-50 min-h-screen'>\n<div id='root'></div>\n<script type='text/babel'>\n" . $html . "\nReactDOM.createRoot(document.getElementById('root')).render(<App />);\n</script>\n</body>\n</html>";
|
||||
}
|
||||
|
||||
// Save as standalone HTML
|
||||
$filename = 'react-' . substr(md5($topic . microtime(true)), 0, 10) . '.html';
|
||||
$outpath = '/var/www/html/files/' . $filename;
|
||||
if (!is_dir('/var/www/html/files')) { mkdir('/var/www/html/files', 0755, true); }
|
||||
|
||||
file_put_contents($outpath, $html);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'preview_url' => '/files/' . $filename,
|
||||
'url' => '/files/' . $filename,
|
||||
'title' => 'React - ' . substr($topic, 0, 50),
|
||||
'code_preview' => substr($html, 0, 2000),
|
||||
'size' => filesize($outpath),
|
||||
'size_kb' => round(filesize($outpath)/1024, 1),
|
||||
'lines' => substr_count($html, "\n"),
|
||||
]);
|
||||
123
api/ambre-tool-xlsx-render.py
Normal file
123
api/ambre-tool-xlsx-render.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ambre-tool-xlsx-render.py - Premium Excel from JSON"""
|
||||
import sys, json
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
PRIMARY = '4f46e5'
|
||||
LIGHT = 'f8fafc'
|
||||
DARK = '0f172a'
|
||||
ACCENT = '10b981'
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3: sys.exit("Usage: render <input.json> <output.xlsx>")
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
spec = json.load(f)
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active) # clean default
|
||||
|
||||
for sheet_data in spec.get('sheets', []):
|
||||
name = sheet_data.get('name', 'Feuille')[:30]
|
||||
ws = wb.create_sheet(name)
|
||||
|
||||
headers = sheet_data.get('headers', [])
|
||||
rows = sheet_data.get('rows', [])
|
||||
|
||||
if not headers: continue
|
||||
|
||||
# Title row (merged)
|
||||
title = spec.get('title','Document')[:60]
|
||||
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
|
||||
title_cell = ws.cell(row=1, column=1, value=title)
|
||||
title_cell.font = Font(name='Calibri', size=16, bold=True, color='FFFFFF')
|
||||
title_cell.fill = PatternFill('solid', fgColor=PRIMARY)
|
||||
title_cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
ws.row_dimensions[1].height = 32
|
||||
|
||||
# Empty row spacer
|
||||
ws.row_dimensions[2].height = 6
|
||||
|
||||
# Header row
|
||||
hdr_border = Border(
|
||||
bottom=Side(style='thick', color=PRIMARY),
|
||||
top=Side(style='thin', color='cbd5e1')
|
||||
)
|
||||
for c_idx, h in enumerate(headers, start=1):
|
||||
cell = ws.cell(row=3, column=c_idx, value=str(h))
|
||||
cell.font = Font(name='Calibri', size=12, bold=True, color='FFFFFF')
|
||||
cell.fill = PatternFill('solid', fgColor=PRIMARY)
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
cell.border = hdr_border
|
||||
ws.row_dimensions[3].height = 28
|
||||
|
||||
# Data rows
|
||||
body_border = Border(
|
||||
top=Side(style='thin', color='e2e8f0'),
|
||||
bottom=Side(style='thin', color='e2e8f0'),
|
||||
left=Side(style='thin', color='e2e8f0'),
|
||||
right=Side(style='thin', color='e2e8f0'),
|
||||
)
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, val in enumerate(row[:len(headers)], start=1):
|
||||
cell = ws.cell(row=4+r_idx, column=c_idx, value=val)
|
||||
cell.font = Font(name='Calibri', size=11, color=DARK)
|
||||
cell.border = body_border
|
||||
# Stripe
|
||||
if r_idx % 2 == 0:
|
||||
cell.fill = PatternFill('solid', fgColor=LIGHT)
|
||||
# Numeric format detection
|
||||
if isinstance(val, (int, float)):
|
||||
cell.alignment = Alignment(horizontal='right')
|
||||
if abs(val) >= 1000:
|
||||
cell.number_format = '#,##0'
|
||||
|
||||
# Totals row
|
||||
totals = sheet_data.get('totals')
|
||||
if totals and isinstance(totals, dict) and 'col' in totals:
|
||||
total_row = 4 + len(rows) + 1
|
||||
col = int(totals['col'])
|
||||
if 0 < col <= len(headers):
|
||||
# Sum numeric values in that column
|
||||
try:
|
||||
numeric_vals = [r[col-1] for r in rows if col-1 < len(r) and isinstance(r[col-1], (int, float))]
|
||||
total_val = sum(numeric_vals)
|
||||
|
||||
lbl_cell = ws.cell(row=total_row, column=1, value=totals.get('label','Total'))
|
||||
lbl_cell.font = Font(name='Calibri', size=12, bold=True, color='FFFFFF')
|
||||
lbl_cell.fill = PatternFill('solid', fgColor=ACCENT)
|
||||
lbl_cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
total_cell = ws.cell(row=total_row, column=col, value=total_val)
|
||||
total_cell.font = Font(name='Calibri', size=12, bold=True, color='FFFFFF')
|
||||
total_cell.fill = PatternFill('solid', fgColor=ACCENT)
|
||||
total_cell.alignment = Alignment(horizontal='right')
|
||||
total_cell.number_format = '#,##0'
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Auto width
|
||||
for c_idx, h in enumerate(headers, start=1):
|
||||
col_letter = get_column_letter(c_idx)
|
||||
max_len = max(
|
||||
[len(str(h))] +
|
||||
[len(str(r[c_idx-1])) if c_idx-1 < len(r) else 0 for r in rows[:30]]
|
||||
)
|
||||
ws.column_dimensions[col_letter].width = min(max(max_len + 3, 12), 40)
|
||||
|
||||
# Freeze panes (title + headers)
|
||||
ws.freeze_panes = 'A4'
|
||||
|
||||
# Auto filter on data
|
||||
if rows:
|
||||
ws.auto_filter.ref = f'A3:{get_column_letter(len(headers))}{3+len(rows)}'
|
||||
|
||||
if not wb.sheetnames:
|
||||
wb.create_sheet('Empty')
|
||||
|
||||
wb.save(sys.argv[2])
|
||||
print(f"OK: {sys.argv[2]} ({len(wb.sheetnames)} sheets)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
107
api/ambre-tool-xlsx.php
Normal file
107
api/ambre-tool-xlsx.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* ambre-tool-xlsx.php - Premium Excel generation
|
||||
* Input: JSON {topic}
|
||||
* Output: JSON {ok, url, sheets, rows, title}
|
||||
*/
|
||||
header('Content-Type: application/json');
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok'=>false, 'error'=>'POST only']); exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$topic = trim($input['topic'] ?? '');
|
||||
if (strlen($topic) < 3) { echo json_encode(['ok'=>false, 'error'=>'topic too short']); exit; }
|
||||
$topic = substr($topic, 0, 500);
|
||||
|
||||
$prompt = "Genere un tableau Excel professionnel structure sur: \"$topic\"\n\n"
|
||||
. "Retourne UNIQUEMENT du JSON valide:\n"
|
||||
. "{\n"
|
||||
. " \"title\": \"Titre fichier\",\n"
|
||||
. " \"sheets\": [\n"
|
||||
. " {\n"
|
||||
. " \"name\": \"Donnees\",\n"
|
||||
. " \"headers\": [\"Colonne1\",\"Colonne2\",\"Colonne3\",\"Colonne4\"],\n"
|
||||
. " \"rows\": [[\"val1\",\"val2\",100,\"2026\"], ...],\n"
|
||||
. " \"totals\": {\"col\": 2, \"label\":\"Total\"}\n"
|
||||
. " },\n"
|
||||
. " {\n"
|
||||
. " \"name\": \"Synthese\",\n"
|
||||
. " \"headers\": [...],\n"
|
||||
. " \"rows\": [...]\n"
|
||||
. " }\n"
|
||||
. " ]\n"
|
||||
. "}\n\n"
|
||||
. "IMPORTANT:\n"
|
||||
. "- 2 a 3 feuilles (sheets)\n"
|
||||
. "- 15-30 lignes de donnees par feuille minimum\n"
|
||||
. "- 4-6 colonnes par feuille avec mix texte/chiffres/dates\n"
|
||||
. "- Donnees realistes et coherentes avec le sujet\n"
|
||||
. "- Include totals sur feuille principale si sens metier\n"
|
||||
. "- Pas d'info confidentielle WEVAL\n"
|
||||
. "- JSON valide uniquement";
|
||||
|
||||
$ch = curl_init('http://127.0.0.1:4000/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => 'auto',
|
||||
'messages' => [['role'=>'user', 'content'=>$prompt]],
|
||||
'max_tokens' => 4500, 'temperature' => 0.6
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 90,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($http !== 200) { echo json_encode(['ok'=>false, 'error'=>"LLM HTTP $http"]); exit; }
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$content_raw = $data['choices'][0]['message']['content'] ?? '';
|
||||
/* BALANCED_JSON_V2 */
|
||||
if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $content_raw, $m)) {
|
||||
$content_raw = $m[1];
|
||||
}
|
||||
$_jstart = strpos($content_raw, '{');
|
||||
if ($_jstart !== false) {
|
||||
$_depth = 0; $_jend = -1;
|
||||
for ($_i = $_jstart; $_i < strlen($content_raw); $_i++) {
|
||||
if ($content_raw[$_i] === '{') $_depth++;
|
||||
elseif ($content_raw[$_i] === '}') { $_depth--; if ($_depth === 0) { $_jend = $_i; break; } }
|
||||
}
|
||||
if ($_jend > $_jstart) $content_raw = substr($content_raw, $_jstart, $_jend - $_jstart + 1);
|
||||
}
|
||||
$spec = json_decode($content_raw, true);
|
||||
if (!$spec || !isset($spec['sheets'])) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'LLM invalid JSON', 'raw'=>substr($content_raw,0,500)]); exit;
|
||||
}
|
||||
|
||||
$tmpjson = tempnam('/tmp', 'xlsx_') . '.json';
|
||||
file_put_contents($tmpjson, json_encode($spec));
|
||||
$filename = 'weval-' . substr(md5($topic . microtime(true)), 0, 10) . '.xlsx';
|
||||
$outpath = '/var/www/html/files/' . $filename;
|
||||
if (!is_dir('/var/www/html/files')) { mkdir('/var/www/html/files', 0755, true); }
|
||||
|
||||
$pyScript = '/var/www/html/api/ambre-tool-xlsx-render.py';
|
||||
$cmd = "python3 " . escapeshellarg($pyScript) . " " . escapeshellarg($tmpjson) . " " . escapeshellarg($outpath) . " 2>&1";
|
||||
$out = shell_exec($cmd);
|
||||
@unlink($tmpjson);
|
||||
|
||||
if (!file_exists($outpath)) {
|
||||
echo json_encode(['ok'=>false, 'error'=>'xlsx render failed', 'py_out'=>substr($out, 0, 500)]); exit;
|
||||
}
|
||||
|
||||
$n_rows = 0;
|
||||
foreach ($spec['sheets'] ?? [] as $s) { $n_rows += count($s['rows'] ?? []); }
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'url' => '/files/' . $filename,
|
||||
'title' => $spec['title'] ?? 'Excel',
|
||||
'sheets' => count($spec['sheets'] ?? []),
|
||||
'rows' => $n_rows,
|
||||
'size' => filesize($outpath),
|
||||
'size_kb' => round(filesize($outpath)/1024, 1),
|
||||
]);
|
||||
332
js/wevia-gen-router.js
Normal file
332
js/wevia-gen-router.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* WEVIA Public - Generation Router V1
|
||||
* Auto-detects user intent (DOCX/XLSX/PPTX/REACT/PDF) from message
|
||||
* Calls corresponding API + shows premium banner + opens preview panel right
|
||||
*
|
||||
* Wiring: loaded as module, hooks into window.sendMsg BEFORE it streams chat
|
||||
* Safe: NO access to vaults, credentials, servers. Public-grade.
|
||||
*/
|
||||
(function(){
|
||||
if (window.__weviaGenRouter) return;
|
||||
window.__weviaGenRouter = true;
|
||||
|
||||
// ============================================================
|
||||
// INTENT DETECTION — regex patterns for natural language
|
||||
// ============================================================
|
||||
var GENERATORS = [
|
||||
{
|
||||
id: 'docx',
|
||||
patterns: [
|
||||
/\b(gen[eè]re?z?|cr[eé]er?|fai[st]|produi[st]?|r[eé]dige?)\s+(un\s+)?(document\s+)?(word|docx|document\s+word)\b/i,
|
||||
/^\s*(word|docx|document\s+word)\s*[:\-]?\s+/i,
|
||||
],
|
||||
api: '/api/ambre-tool-docx.php',
|
||||
label: 'Document Word',
|
||||
icon: '📄',
|
||||
color: '#2563eb',
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
patterns: [
|
||||
/\b(gen[eè]re?z?|cr[eé]er?|fai[st]|produi[st]?)\s+(un\s+)?(tableau|fichier|document)?\s*(excel|xlsx|tableur|spreadsheet)\b/i,
|
||||
/^\s*(excel|xlsx|tableau\s+excel)\s*[:\-]?\s+/i,
|
||||
],
|
||||
api: '/api/ambre-tool-xlsx.php',
|
||||
label: 'Tableau Excel',
|
||||
icon: '📊',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
patterns: [
|
||||
/\b(gen[eè]re?z?|cr[eé]er?|fai[st]|produi[st]?)\s+(une?\s+)?(pr[eé]sentation|slide|deck|ppt|pptx|powerpoint)\b/i,
|
||||
/^\s*(ppt|pptx|powerpoint|présentation)\s*[:\-]?\s+/i,
|
||||
],
|
||||
api: '/api/ambre-tool-pptx.php',
|
||||
label: 'Présentation PowerPoint',
|
||||
icon: '🎬',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'react',
|
||||
patterns: [
|
||||
/\b(gen[eè]re?z?|cr[eé]er?|fai[st]|build|construis)\s+(un\s+)?(composant|component|page|app|widget|landing|dashboard|pricing|form)\s+(react|frontend|front[- ]end|web|html)/i,
|
||||
/\b(compose|gen[eè]re?)\s+(un\s+)?(react|html|front[- ]end)\b/i,
|
||||
],
|
||||
api: '/api/ambre-tool-react.php',
|
||||
label: 'Composant React',
|
||||
icon: '⚛️',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
];
|
||||
|
||||
function detectIntent(text) {
|
||||
if (!text || text.length < 8) return null;
|
||||
for (var i = 0; i < GENERATORS.length; i++) {
|
||||
var g = GENERATORS[i];
|
||||
for (var j = 0; j < g.patterns.length; j++) {
|
||||
if (g.patterns[j].test(text)) return g;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TOPIC EXTRACTION — clean prompt from intent keywords
|
||||
// ============================================================
|
||||
function extractTopic(text, gen) {
|
||||
var t = text;
|
||||
// Strip common generation verbs
|
||||
t = t.replace(/^\s*(gen[eè]re?z?|cr[eé]er?|fai[st]?|produi[st]?|r[eé]dige?|build|compose|construis)\s+/i, '');
|
||||
// Strip format keywords
|
||||
t = t.replace(/\b(un|une|le|la|des)\s+/i, '');
|
||||
t = t.replace(/\b(document|tableau|fichier|composant|component|presentation|slide|deck)\s+/i, '');
|
||||
t = t.replace(/\b(word|docx|excel|xlsx|ppt|pptx|powerpoint|react|html|front[- ]?end)\b\s*/gi, '');
|
||||
// Strip "sur", "pour", "de", ":" at start
|
||||
t = t.replace(/^\s*(sur|pour|de|du|:|\-|about|propos)\s+/i, '');
|
||||
t = t.replace(/^[:\-\s]+/, '');
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BANNER UI — progress + result with download
|
||||
// ============================================================
|
||||
function createBanner(gen, topic) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'wgen-banner';
|
||||
el.setAttribute('role','status');
|
||||
el.style.cssText =
|
||||
'margin:14px 0;padding:14px 18px;border-radius:14px;' +
|
||||
'background:linear-gradient(135deg,' + gen.color + ',' + shade(gen.color, -15) + ');' +
|
||||
'color:#fff;display:flex;align-items:center;gap:14px;' +
|
||||
'box-shadow:0 4px 20px ' + gen.color + '44;' +
|
||||
'animation:wgenSlide .4s cubic-bezier(.4,0,.2,1);font-family:inherit';
|
||||
el.innerHTML =
|
||||
'<div style="font-size:28px;flex-shrink:0">' + gen.icon + '</div>' +
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:700;font-size:14px;margin-bottom:3px">Génération ' + gen.label + '…</div>' +
|
||||
'<div style="font-size:12px;opacity:.92;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" class="wgen-topic">' + escapeHtml(topic) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="wgen-spin" style="width:20px;height:20px;border:3px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:wgenSpin .7s linear infinite"></div>';
|
||||
return el;
|
||||
}
|
||||
|
||||
function finalizeBanner(el, gen, result, error) {
|
||||
var spin = el.querySelector('.wgen-spin'); if (spin) spin.remove();
|
||||
if (error) {
|
||||
el.style.background = 'linear-gradient(135deg,#dc2626,#991b1b)';
|
||||
el.innerHTML =
|
||||
'<div style="font-size:28px;flex-shrink:0">❌</div>' +
|
||||
'<div style="flex:1"><div style="font-weight:700;font-size:14px">Erreur génération ' + gen.label + '</div>' +
|
||||
'<div style="font-size:12px;opacity:.92">' + escapeHtml(error) + '</div></div>' +
|
||||
'<button onclick="this.parentElement.remove()" style="padding:6px 12px;background:rgba(255,255,255,.25);color:#fff;border:none;border-radius:8px;font-weight:600;cursor:pointer;font-size:12px">Fermer</button>';
|
||||
return;
|
||||
}
|
||||
el.style.background = 'linear-gradient(135deg,#10b981,#059669)';
|
||||
var details = [];
|
||||
if (result.sections) details.push(result.sections + ' sections');
|
||||
if (result.slides) details.push(result.slides + ' slides');
|
||||
if (result.sheets) details.push(result.sheets + ' feuilles');
|
||||
if (result.rows) details.push(result.rows + ' lignes');
|
||||
if (result.lines) details.push(result.lines + ' lignes code');
|
||||
if (result.size_kb) details.push(result.size_kb + ' KB');
|
||||
var detailsTxt = details.length ? details.join(' · ') : 'Fichier prêt';
|
||||
|
||||
el.innerHTML =
|
||||
'<div style="font-size:28px;flex-shrink:0">' + gen.icon + '</div>' +
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:700;font-size:14px;margin-bottom:3px">' + gen.label + ' généré ✓</div>' +
|
||||
'<div style="font-size:12px;opacity:.95;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(result.title || '') + ' · ' + detailsTxt + '</div>' +
|
||||
'</div>' +
|
||||
'<button onclick="weviaOpenPreview(\'' + (result.url || result.preview_url || '') + '\',\'' + gen.id + '\',\'' + escapeAttr(result.title || gen.label) + '\')" style="padding:8px 14px;background:rgba(255,255,255,.22);color:#fff;border:1px solid rgba(255,255,255,.35);border-radius:8px;font-weight:600;font-size:12px;cursor:pointer;white-space:nowrap;margin-right:6px">👁 Aperçu</button>' +
|
||||
'<a href="' + (result.url || result.preview_url) + '" download target="_blank" style="padding:8px 14px;background:#fff;color:' + shade('#10b981',-5) + ';border:none;border-radius:8px;font-weight:700;font-size:12px;text-decoration:none;white-space:nowrap">⬇ Télécharger</a>';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PREVIEW PANEL — use existing .preview-panel infra
|
||||
// ============================================================
|
||||
window.weviaOpenPreview = function(url, kind, title) {
|
||||
try {
|
||||
var layout = document.querySelector('.main-layout');
|
||||
var body = document.getElementById('previewBody');
|
||||
var titleEl = document.getElementById('prevTitleText');
|
||||
if (!layout || !body) { window.open(url, '_blank'); return; }
|
||||
|
||||
if (titleEl) titleEl.textContent = title || 'Document';
|
||||
|
||||
var html = '';
|
||||
if (kind === 'react') {
|
||||
html = '<iframe src="' + url + '" style="width:100%;height:100%;min-height:600px;border:0;background:#fff" sandbox="allow-scripts allow-same-origin"></iframe>';
|
||||
} else if (kind === 'docx' || kind === 'xlsx' || kind === 'pptx') {
|
||||
// Google Docs Viewer for Office files
|
||||
var absUrl = url.startsWith('http') ? url : window.location.origin + url;
|
||||
html =
|
||||
'<iframe src="https://docs.google.com/viewer?url=' + encodeURIComponent(absUrl) + '&embedded=true" style="width:100%;height:100%;min-height:600px;border:0;background:#fff"></iframe>';
|
||||
} else if (kind === 'pdf') {
|
||||
html = '<iframe src="' + url + '" style="width:100%;height:100%;min-height:600px;border:0;background:#fff"></iframe>';
|
||||
} else {
|
||||
html = '<iframe src="' + url + '" style="width:100%;height:100%;min-height:600px;border:0"></iframe>';
|
||||
}
|
||||
body.innerHTML = html;
|
||||
layout.classList.add('panel-open');
|
||||
|
||||
// Setup download button
|
||||
window.__lastPreviewUrl = url;
|
||||
} catch (e) {
|
||||
console.warn('openPreview err', e);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// Wire existing downloadPreview button
|
||||
var oldDl = window.downloadPreview;
|
||||
window.downloadPreview = function() {
|
||||
if (window.__lastPreviewUrl) {
|
||||
var a = document.createElement('a');
|
||||
a.href = window.__lastPreviewUrl;
|
||||
a.download = '';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function(){ a.remove(); }, 100);
|
||||
return;
|
||||
}
|
||||
if (typeof oldDl === 'function') oldDl();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// HOOK into sendMsg — intercept before normal chat flow
|
||||
// ============================================================
|
||||
function injectCSS() {
|
||||
if (document.getElementById('wgen-style')) return;
|
||||
var s = document.createElement('style');
|
||||
s.id = 'wgen-style';
|
||||
s.textContent =
|
||||
'@keyframes wgenSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}' +
|
||||
'@keyframes wgenSpin{to{transform:rotate(360deg)}}' +
|
||||
'.wgen-banner{font-family:inherit}';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, function(c) {
|
||||
return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c];
|
||||
});
|
||||
}
|
||||
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); }
|
||||
|
||||
function shade(hex, percent) {
|
||||
var num = parseInt(hex.replace('#',''), 16);
|
||||
var r = (num >> 16) + Math.floor(percent * 2.55);
|
||||
var g = ((num >> 8) & 0xff) + Math.floor(percent * 2.55);
|
||||
var b = (num & 0xff) + Math.floor(percent * 2.55);
|
||||
r = Math.max(0, Math.min(255, r));
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
b = Math.max(0, Math.min(255, b));
|
||||
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
// Get messages container to append banner
|
||||
function getMessagesContainer() {
|
||||
return document.getElementById('messages') ||
|
||||
document.querySelector('.chat-messages') ||
|
||||
document.querySelector('.messages-container') ||
|
||||
document.querySelector('main') ||
|
||||
document.body;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GENERATE — async call + banner lifecycle
|
||||
// ============================================================
|
||||
async function generateArtifact(gen, topic) {
|
||||
injectCSS();
|
||||
var banner = createBanner(gen, topic);
|
||||
var container = getMessagesContainer();
|
||||
container.appendChild(banner);
|
||||
banner.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
try {
|
||||
var r = await fetch(gen.api, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic: topic })
|
||||
});
|
||||
var data = await r.json();
|
||||
|
||||
if (data.ok) {
|
||||
finalizeBanner(banner, gen, data, null);
|
||||
// Auto-open preview
|
||||
setTimeout(function() {
|
||||
weviaOpenPreview(data.url || data.preview_url, gen.id, data.title);
|
||||
}, 600);
|
||||
} else {
|
||||
finalizeBanner(banner, gen, null, data.error || 'Erreur inconnue');
|
||||
}
|
||||
} catch (e) {
|
||||
finalizeBanner(banner, gen, null, e.message || 'Réseau');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INTERCEPT sendMsg — wrapper
|
||||
// ============================================================
|
||||
function wrapSendMsg() {
|
||||
if (typeof window.sendMsg !== 'function') {
|
||||
setTimeout(wrapSendMsg, 500);
|
||||
return;
|
||||
}
|
||||
if (window.__sendMsgWrapped) return;
|
||||
window.__sendMsgWrapped = true;
|
||||
|
||||
var originalSend = window.sendMsg;
|
||||
window.sendMsg = function() {
|
||||
try {
|
||||
var input = document.getElementById('msgInput');
|
||||
if (!input) return originalSend.apply(this, arguments);
|
||||
var text = input.value.trim();
|
||||
if (!text) return originalSend.apply(this, arguments);
|
||||
|
||||
var intent = detectIntent(text);
|
||||
if (intent) {
|
||||
var topic = extractTopic(text, intent);
|
||||
if (topic.length < 3) topic = text;
|
||||
|
||||
// Echo user message first via normal flow - then intercept before API call
|
||||
// Clear input + call generator
|
||||
input.value = '';
|
||||
try { input.style.height = 'auto'; } catch(e) {}
|
||||
|
||||
// Show user bubble (minimal)
|
||||
var container = getMessagesContainer();
|
||||
var userBubble = document.createElement('div');
|
||||
userBubble.style.cssText = 'margin:12px 0;padding:10px 14px;background:#6366f1;color:#fff;border-radius:14px 14px 4px 14px;max-width:80%;margin-left:auto;font-size:14px;word-wrap:break-word';
|
||||
userBubble.textContent = text;
|
||||
container.appendChild(userBubble);
|
||||
|
||||
// Trigger generator
|
||||
generateArtifact(intent, topic);
|
||||
return; // don't call original (would double-post)
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('wgen hook err', e);
|
||||
}
|
||||
return originalSend.apply(this, arguments);
|
||||
};
|
||||
console.log('[wevia-gen-router] hooked sendMsg');
|
||||
}
|
||||
|
||||
// Start
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', wrapSendMsg);
|
||||
} else {
|
||||
wrapSendMsg();
|
||||
}
|
||||
|
||||
// Expose for manual debug
|
||||
window.weviaGenRouter = {
|
||||
detectIntent: detectIntent,
|
||||
extractTopic: extractTopic,
|
||||
generate: generateArtifact,
|
||||
generators: GENERATORS,
|
||||
};
|
||||
})();
|
||||
@@ -626,6 +626,9 @@
|
||||
<button type="button" class="wv-sc" onclick="wvShortcut('Traduis ce texte en: ')" title="Traduit vers une autre langue (FR, EN, AR, ES, DE...)">
|
||||
<span class="wv-sc-ico" style="background:linear-gradient(135deg,#06b6d4,#0891b2)">🌐</span>
|
||||
<span class="wv-sc-lbl">Traduire</span>
|
||||
</button><button type="button" class="wv-sc" onclick="wvShortcut('Genere composant React pour: ')" title="Traduit vers une autre langue (FR, EN, AR, ES, DE...)">
|
||||
<span class="wv-sc-ico" style="background:linear-gradient(135deg,#06b6d4,#0891b2)">🌐</span>
|
||||
<span class="wv-sc-lbl">React</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sb-section">Récents</div>
|
||||
@@ -3707,7 +3710,7 @@ function addSmartThinkSteps(query) {
|
||||
|
||||
<script src="/api/a11y-auto-enhancer.js" defer></script>
|
||||
<!-- nav dock --><script src="/wtp-unified-dock.js" defer></script>
|
||||
<script src="/opus-antioverlap-doctrine.js?v=1776776094" defer></script>
|
||||
<script src="/opus-antioverlap-doctrine.js?v=1777045903" defer></script>
|
||||
|
||||
<!-- Opus v17 · WEVIA-Pattern SSE (auto-injected) -->
|
||||
<style id="opus-pattern-style">
|
||||
@@ -3808,5 +3811,7 @@ function addSmartThinkSteps(query) {
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- WEVIA-GEN-ROUTER V1 - auto-detect docx/xlsx/pptx/react intents -->
|
||||
<script src="/js/wevia-gen-router.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user