You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
400 lines
17 KiB
400 lines
17 KiB
# -*- coding: utf-8 -*-
|
|
#############################################################################
|
|
#
|
|
# Cybrosys Technologies Pvt. Ltd.
|
|
#
|
|
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>).
|
|
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
|
|
#
|
|
# You can modify it under the terms of the GNU AFFERO
|
|
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
|
#
|
|
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
|
# (AGPL v3) along with this program.
|
|
# If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
#############################################################################
|
|
import hashlib
|
|
import re
|
|
import requests
|
|
from markupsafe import Markup
|
|
from openai import OpenAI
|
|
from odoo import http
|
|
from odoo.http import request
|
|
|
|
|
|
def preprocess_ai_output(text):
|
|
"""
|
|
Preprocesses AI-generated text by carefully extracting and closing CSS rules.
|
|
Args:
|
|
text (str): Input text containing HTML and CSS
|
|
Returns:
|
|
str: Preprocessed text with properly closed CSS
|
|
"""
|
|
text = re.sub(r'^```(html|css|plaintext)?\s*', '', text.strip(), flags=re.MULTILINE)
|
|
text = re.sub(r'\s*```$', '', text, flags=re.MULTILINE)
|
|
# Check if style tags already exist
|
|
style_match = re.search(r'<style>(.*?)</style>', text, re.DOTALL)
|
|
if style_match:
|
|
return text
|
|
css_lines = []
|
|
html_lines = []
|
|
current_rule = []
|
|
in_css_block = False
|
|
for line in text.split('\n'):
|
|
stripped_line = line.strip()
|
|
if re.match(r'^(\.|#|[a-zA-Z])[\w\s,#>:.()-]+\s*{', stripped_line):
|
|
in_css_block = True
|
|
current_rule = [line]
|
|
elif in_css_block:
|
|
current_rule.append(line)
|
|
if stripped_line.endswith('}'):
|
|
css_lines.extend(current_rule)
|
|
current_rule = []
|
|
in_css_block = False
|
|
else:
|
|
if stripped_line:
|
|
html_lines.append(line)
|
|
if current_rule:
|
|
if not current_rule[-1].strip().endswith('}'):
|
|
current_rule[-1] = current_rule[-1] + '\n}'
|
|
css_lines.extend(current_rule)
|
|
if css_lines:
|
|
css_output = "<style>\n" + "\n".join(css_lines) + "\n</style>"
|
|
else:
|
|
css_output = ""
|
|
html_output = "\n".join(html_lines)
|
|
final_output = f"{css_output}\n{html_output}" if css_output else html_output
|
|
return final_output.strip()
|
|
|
|
|
|
class SnippetGenerator(http.Controller):
|
|
|
|
|
|
def _generate_unique_id(self, snippet_name):
|
|
"""Generate a unique ID for the snippet to avoid CSS conflicts"""
|
|
return hashlib.md5(f"{snippet_name}_{request.session.sid}".encode()).hexdigest()[
|
|
:8]
|
|
|
|
def _generate_openai_response(self, prompt, api_key, model_id, max_tokens,
|
|
snippet_name):
|
|
"""Helper method for OpenAI API calls"""
|
|
response = OpenAI(api_key=api_key).chat.completions.create(
|
|
model=model_id,
|
|
messages=[
|
|
{"role": "system",
|
|
"content": "You are a helpful assistant that generates website snippets similar to Odoo's inbuilt website snippets. "
|
|
"Create responsive snippets using Bootstrap classes, ensuring proper container and row/column structure. "
|
|
"Use placeholder images from 'https://via.placeholder.com/' with appropriate sizes. "
|
|
"Return only clean HTML and CSS without any wrapper divs or preview elements. "
|
|
"Do not include any code block markers (```). "
|
|
"Focus on creating functional, beautiful snippets that work well in Odoo's website builder. "
|
|
"Use modern design principles with good typography, spacing, and colors."
|
|
},
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
max_tokens=int(max_tokens),
|
|
temperature=0.7
|
|
)
|
|
return response.choices[0].message.content.strip()
|
|
|
|
def _generate_openrouter_response(self, prompt, api_key, model_id, max_tokens,
|
|
snippet_name):
|
|
"""Helper method for OpenRouter API calls with improved error handling"""
|
|
url = "https://openrouter.ai/api/v1/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"HTTP-Referer": request.env['ir.config_parameter'].sudo().get_param(
|
|
'web.base.url', ''),
|
|
"X-Title": "Odoo Integration"
|
|
}
|
|
payload = {
|
|
"model": model_id,
|
|
"messages": [
|
|
{"role": "system",
|
|
"content": "You are a helpful assistant that generates website snippets similar to Odoo's inbuilt website snippets. "
|
|
"Create responsive snippets using Bootstrap classes, ensuring proper container and row/column structure. "
|
|
"Use placeholder images from 'https://via.placeholder.com/' with appropriate sizes. "
|
|
"Return only clean HTML and CSS without any wrapper divs or preview elements. "
|
|
"Do not include any code block markers (```). "
|
|
"Focus on creating functional, beautiful snippets that work well in Odoo's website builder. "
|
|
"Use modern design principles with good typography, spacing, and colors."
|
|
},
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
"max_tokens": int(max_tokens),
|
|
"temperature": 0.7
|
|
}
|
|
response = requests.post(url, headers=headers, json=payload)
|
|
if response.status_code != 200:
|
|
raise Exception(
|
|
f"OpenRouter API error: {response.status_code} - {response.text}")
|
|
json_response = response.json()
|
|
if 'choices' not in json_response or not json_response['choices']:
|
|
raise Exception("Invalid response format from OpenRouter API")
|
|
choice = json_response['choices'][0]
|
|
if 'message' not in choice:
|
|
raise Exception("No message in OpenRouter API response")
|
|
message = choice['message']
|
|
content = message.get('content', '')
|
|
reasoning = message.get('reasoning', '')
|
|
partial_content = content.strip() if content else reasoning.strip()
|
|
if choice.get('finish_reason') == 'length' and partial_content:
|
|
completion_prompt = f"""
|
|
You previously started generating a website snippet but it was cut off.
|
|
Please complete the snippet based on this partial content:
|
|
{partial_content}
|
|
Continue from where it was cut off and make sure the HTML/CSS is complete and valid.
|
|
"""
|
|
completion_payload = {
|
|
"model": model_id,
|
|
"messages": [
|
|
{"role": "system",
|
|
"content": "You are completing a partially generated website snippet. "
|
|
"Return only HTML/CSS code to complete the snippet, don't start over."},
|
|
{"role": "user", "content": completion_prompt}
|
|
],
|
|
"max_tokens": int(max_tokens),
|
|
"temperature": 0.7
|
|
}
|
|
try:
|
|
completion_response = requests.post(url, headers=headers,
|
|
json=completion_payload)
|
|
completion_response.raise_for_status()
|
|
completion_json = completion_response.json()
|
|
if 'choices' in completion_json and completion_json['choices']:
|
|
completion_message = completion_json['choices'][0]['message']
|
|
completion_content = completion_message.get('content', '')
|
|
if completion_content:
|
|
cleaned_completion = re.sub(r"^```(html|css)?\s*", "",
|
|
completion_content.strip(),
|
|
flags=re.MULTILINE)
|
|
cleaned_completion = re.sub(r"\s*```$", "", cleaned_completion,
|
|
flags=re.MULTILINE)
|
|
combined_content = partial_content + "\n" + cleaned_completion
|
|
return combined_content
|
|
return partial_content
|
|
except Exception:
|
|
return partial_content + "\n<!-- Note: This snippet is incomplete. The system attempted to complete it but encountered an error. -->"
|
|
if content:
|
|
return content.strip()
|
|
elif reasoning:
|
|
return reasoning.strip()
|
|
else:
|
|
raise Exception(
|
|
"Empty response from AI model. Please try again with a "
|
|
"different prompt or model.")
|
|
|
|
@http.route('/website/generate_snippet', type='json', auth='user', website=True)
|
|
def generate_snippet(self, **kwargs):
|
|
"""Single route to handle both OpenAI and OpenRouter snippet generation"""
|
|
prompt = kwargs.get('prompt')
|
|
snippet_name = kwargs.get('name')
|
|
if not prompt:
|
|
return {'error': "Prompt is required"}
|
|
try:
|
|
api_key = request.env['ir.config_parameter'].sudo().get_param('api_key')
|
|
ai_system = request.env['ir.config_parameter'].sudo().get_param(
|
|
'generative_ai_systems')
|
|
max_tokens = request.env['ir.config_parameter'].sudo().get_param('max_token')
|
|
if not api_key:
|
|
return {'error': "API key not configured"}
|
|
unique_id = self._generate_unique_id(snippet_name)
|
|
try:
|
|
if ai_system == 'openai':
|
|
model_id = request.env['ir.config_parameter'].sudo().get_param(
|
|
'model_id')
|
|
if not model_id:
|
|
return {'error': "No OpenAI model selected"}
|
|
snippet_text = self._generate_openai_response(prompt, api_key,
|
|
model_id, max_tokens,
|
|
snippet_name)
|
|
elif ai_system == 'openrouter':
|
|
model_id = request.env['ir.config_parameter'].sudo().get_param(
|
|
'openrouter_model_id')
|
|
if not model_id or model_id == 'none':
|
|
return {'error': "No OpenRouter model selected"}
|
|
snippet_text = self._generate_openrouter_response(prompt, api_key,
|
|
model_id,
|
|
max_tokens,
|
|
snippet_name)
|
|
else:
|
|
return {'error': "Invalid AI system selected"}
|
|
except Exception as api_error:
|
|
return {'error': f"API Error: {str(api_error)}", 'retry': True}
|
|
# Check if we got a valid response
|
|
if not snippet_text or snippet_text.strip() == "":
|
|
return {
|
|
'error': "AI did not generate any content. Please try again with a different prompt or model.",
|
|
'retry': True
|
|
}
|
|
processed_snippet_text = preprocess_ai_output(snippet_text)
|
|
preview_style = f"""
|
|
<style>
|
|
.snippet-preview-{unique_id} {{
|
|
position: relative !important;
|
|
min-width: 600px !important;
|
|
min-height: 400px !important;
|
|
width: 100% !important;
|
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%) !important;
|
|
border-radius: 12px !important;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important;
|
|
overflow: visible !important;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
display: flex !important;
|
|
flex-direction: column !important;
|
|
align-items: center !important;
|
|
justify-content: center !important;
|
|
padding: 20px !important;
|
|
box-sizing: border-box !important;
|
|
cursor: pointer !important;
|
|
}}
|
|
|
|
.snippet-preview-{unique_id}:hover {{
|
|
transform: translateY(-5px) !important;
|
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important;
|
|
}}
|
|
|
|
.snippet-preview-{unique_id}::before {{
|
|
content: '' !important;
|
|
position: absolute !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
height: 4px !important;
|
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%) !important;
|
|
z-index: 1 !important;
|
|
}}
|
|
|
|
.snippet-name-{unique_id} {{
|
|
position: absolute !important;
|
|
top: -10px !important;
|
|
left: 50% !important;
|
|
transform: translateX(-50%) !important;
|
|
background: rgba(255, 255, 255, 0.95) !important;
|
|
backdrop-filter: blur(10px) !important;
|
|
-webkit-backdrop-filter: blur(10px) !important;
|
|
color: #2d3748 !important;
|
|
padding: 12px 20px !important;
|
|
border-radius: 25px !important;
|
|
font-size: 16px !important;
|
|
font-weight: 600 !important;
|
|
letter-spacing: 0.5px !important;
|
|
opacity: 0 !important;
|
|
visibility: hidden !important;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
z-index: 1000 !important;
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
|
white-space: nowrap !important;
|
|
pointer-events: none !important;
|
|
min-width: 120px !important;
|
|
text-align: center !important;
|
|
}}
|
|
|
|
.snippet-preview-{unique_id}:hover .snippet-name-{unique_id} {{
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
transform: translateX(-50%) translateY(-8px) !important;
|
|
pointer-events: auto !important;
|
|
}}
|
|
|
|
/* Alternative hover trigger - also trigger on any child hover */
|
|
.snippet-preview-{unique_id} *:hover ~ .snippet-name-{unique_id},
|
|
.snippet-preview-{unique_id} .snippet-content-{unique_id}:hover ~ .snippet-name-{unique_id} {{
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
transform: translateX(-50%) translateY(-8px) !important;
|
|
}}
|
|
|
|
.snippet-content-{unique_id} {{
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
z-index: 2 !important;
|
|
position: relative !important;
|
|
background: white !important;
|
|
border-radius: 8px !important;
|
|
padding: 20px !important;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
|
|
margin-top: 30px !important;
|
|
}}
|
|
|
|
/* Ensure content doesn't overflow */
|
|
.snippet-content-{unique_id} * {{
|
|
max-width: 100% !important;
|
|
box-sizing: border-box !important;
|
|
}}
|
|
|
|
/* Style for cards and common elements */
|
|
.snippet-content-{unique_id} .card {{
|
|
border: none !important;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
|
|
border-radius: 8px !important;
|
|
transition: transform 0.2s ease !important;
|
|
}}
|
|
|
|
.snippet-content-{unique_id} .card:hover {{
|
|
transform: translateY(-2px) !important;
|
|
}}
|
|
|
|
.snippet-content-{unique_id} .btn {{
|
|
border-radius: 6px !important;
|
|
font-weight: 500 !important;
|
|
transition: all 0.2s ease !important;
|
|
}}
|
|
|
|
.snippet-content-{unique_id} .btn-primary {{
|
|
background: linear-gradient(45deg, #667eea, #764ba2) !important;
|
|
border: none !important;
|
|
}}
|
|
|
|
.snippet-content-{unique_id} .btn-primary:hover {{
|
|
background: linear-gradient(45deg, #5a67d8, #6b46c1) !important;
|
|
transform: translateY(-1px) !important;
|
|
}}
|
|
|
|
/* Fallback JavaScript-free hover detection */
|
|
.snippet-preview-{unique_id}:focus-within .snippet-name-{unique_id} {{
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
transform: translateX(-50%) translateY(-8px) !important;
|
|
}}
|
|
</style>"""
|
|
|
|
# Wrap content with unique scoped classes
|
|
wrapped_content = f"""
|
|
{preview_style}
|
|
<div class="snippet-preview-{unique_id}" title="{snippet_name}">
|
|
<div class="snippet-name-{unique_id}">{snippet_name}</div>
|
|
<div class="snippet-content-{unique_id}">
|
|
{processed_snippet_text}
|
|
</div>
|
|
</div>
|
|
"""
|
|
if not wrapped_content or wrapped_content.strip() == "":
|
|
return {
|
|
'error': "Could not process AI output into a valid snippet. Please try again.",
|
|
'retry': True
|
|
}
|
|
snippet = request.env['website.snippet.data'].sudo().create({
|
|
'name': snippet_name,
|
|
'content': Markup(wrapped_content),
|
|
'image_url': '/generative_ai/static/src/img/placeholder.png',
|
|
'is_ai_generated': True,
|
|
})
|
|
request.env['ir.qweb'].clear_caches()
|
|
request.env['ir.ui.view'].clear_caches()
|
|
return {
|
|
'success': True,
|
|
'snippet': snippet,
|
|
'snippet_id': snippet.id,
|
|
'content': snippet.content,
|
|
'image_url': '/generative_ai/static/src/img/placeholder.png'
|
|
}
|
|
except Exception as e:
|
|
return {'error': str(e), 'retry': True}
|
|
|