Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@ Odoo-style versioning (`17.0.MAJOR.MINOR.PATCH`). Add new entries under
gate.

### Added
- **Rendered charts & HTML reports in chat** — the assistant can now produce
visual output instead of raw markup. HTML emitted in a fenced ` ```html `
block (or an untagged fence whose content is HTML) renders as a CSS/SVG chart
or report inside a **sandboxed iframe** (`sandbox="allow-same-origin"`, no
scripts — styles are isolated and the markup's own JS never runs). Rendered
output opens in a dedicated **artifact panel on the right**, with the latest
chart auto-opening and inline "Chart" cards to reopen any previous one. A new
`report` message role plus a `codexoo.session._post_report(html)` helper let
server-side code push a full report straight into the conversation.

- **Contribution gates** — a `pr-checks` CI workflow (CHANGELOG, DCO sign-off,
SECURITY.md-when-relevant, flake8) plus branch protection, and `CONTRIBUTING.md`
guidance covering AI-assisted contributions and the Developer Certificate of
Origin (`DCO`).

### Changed
- **Tool calls are collapsed by default** in each assistant message, behind a
toggle that shows the call count (and a spinner while any call is still
running); expand to see the per-tool chips.

## [17.0.1.0.3] — 2026-06-11

### Changed
Expand Down
13 changes: 10 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ are in scope for security reports include:
boundary is server-side (the loopback bridge token + per-user ACLs).
- **Per-user credential isolation** — ChatGPT login credentials are stored per user
in a private `CODEX_HOME` with mode `0600` and never exposed on a record.
- **Sandboxed report/chart rendering** — HTML the model emits (charts, reports,
and `codexoo.session._post_report` output) is rendered in an
`<iframe sandbox="allow-same-origin">` **without** `allow-scripts`, so its own
scripts never execute and its styles are isolated from the Odoo UI; the parent
only reads the frame to size it. `allow-scripts` is deliberately never combined
with `allow-same-origin` (that pairing lets a frame drop its own sandbox).

If you find a way to (a) escalate beyond the acting user's ACLs, (b) mutate data
through `sql_select`, (c) forge or replay a bridge token, or (d) make the model
affect Odoo or the host outside the `mcp__odoo__*` tool surface, that is a
security bug — please report it.
through `sql_select`, (c) forge or replay a bridge token, (d) make the model
affect Odoo or the host outside the `mcp__odoo__*` tool surface, or (e) execute
script or escape the iframe sandbox via rendered model HTML, that is a security
bug — please report it.
3 changes: 3 additions & 0 deletions codexoo/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
'codexoo/static/src/scss/codexoo.scss',
'codexoo/static/src/codexoo_service.js',
'codexoo/static/src/markdown.js',
'codexoo/static/src/artifacts.js',
'codexoo/static/src/components/report_frame.js',
'codexoo/static/src/components/report_frame.xml',
'codexoo/static/src/components/message.js',
'codexoo/static/src/components/message.xml',
'codexoo/static/src/components/message_list.js',
Expand Down
4 changes: 2 additions & 2 deletions codexoo/models/codexoo_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright 2026 CICDoo (https://cicdoo.com)
# SPDX-License-Identifier: LGPL-3.0-or-later OR LicenseRef-Codexoo-Commercial
# Dual-licensed: open source (LGPL-3, see LICENSE) or commercial (see COMMERCIAL_LICENSE.md).
from odoo import api, fields, models
from odoo import fields, models


class AiAssistantMessage(models.Model):
Expand All @@ -14,7 +14,7 @@ class AiAssistantMessage(models.Model):
"codexoo.session", required=True, ondelete="cascade", index=True)
role = fields.Selection(
[("user", "User"), ("assistant", "Assistant"),
("tool", "Tool"), ("error", "Error")],
("tool", "Tool"), ("report", "Report"), ("error", "Error")],
required=True, default="assistant")
body = fields.Text()
# List of {id, name, input, result, status} dicts for tool-call chips.
Expand Down
21 changes: 21 additions & 0 deletions codexoo/models/codexoo_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,27 @@ def send_message(self, body, attachment_ids=None):
self.env["codexoo.runner"]._launch(self, prompt, is_first, token)
return {"message_id": user_msg.id, "session_id": self.id}

def _post_report(self, html):
"""Create a ``report``-role message holding raw report HTML and push it
to the live chat.

``html`` is rendered in the OWL UI inside a sandboxed iframe (scripts
blocked, styles isolated), so it may be a full HTML document — e.g. the
output of ``ir.actions.report._render_qweb_html``. Reuses the runner's
bus broadcast so an open chat updates immediately, just like streamed
assistant messages.

Returns the created ``codexoo.message`` record.
"""
self.ensure_one()
rec = self.env["codexoo.message"].create({
"session_id": self.id,
"role": "report",
"body": html or "",
})
self.env["codexoo.runner"]._emit_message(self, rec)
return rec

def _build_prompt(self, body, paths):
"""Append an attachment manifest to the user's text so the model knows
which uploaded files it may open from its working directory (the
Expand Down
42 changes: 42 additions & 0 deletions codexoo/static/src/artifacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** @odoo-module **/
// Copyright 2026 CICDoo (https://cicdoo.com)
// SPDX-License-Identifier: LGPL-3.0-or-later OR LicenseRef-Codexoo-Commercial
// Dual-licensed: open source (LGPL-3, see LICENSE) or commercial (see COMMERCIAL_LICENSE.md).

// Pull embedded HTML out of an assistant reply so it can render visually (e.g. a
// CSS/SVG chart) in the artifact panel instead of showing as a code block. We
// only break the text at HTML blocks: a fence tagged ```html, or an untagged
// ``` fence whose content starts with a tag. Every other fenced block (json,
// python, …) is left inside the surrounding markdown run so normal replies
// aren't fragmented. An unclosed fence (mid-stream) stays markdown until its
// closing ``` arrives.
export function splitSegments(text) {
const re = /```([^\n`]*)\n([\s\S]*?)```/g;
const segs = [];
let last = 0;
let m;
while ((m = re.exec(text))) {
const lang = (m[1] || "").trim().toLowerCase();
const content = m[2];
const isHtml = lang === "html" || (lang === "" && /^\s*<[!a-zA-Z]/.test(content));
if (!isHtml) {
continue; // leave non-HTML code blocks in the markdown run
}
if (m.index > last) {
segs.push({ type: "md", text: text.slice(last, m.index) });
}
segs.push({ type: "html", text: content });
last = re.lastIndex;
}
if (last < text.length) {
segs.push({ type: "md", text: text.slice(last) });
}
return segs;
}

// Just the embedded HTML block strings, in order.
export function extractHtmlBlocks(text) {
return splitSegments(text)
.filter((s) => s.type === "html")
.map((s) => s.text);
}
68 changes: 67 additions & 1 deletion codexoo/static/src/chat_action.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { AiMessageList } from "./components/message_list";
import { AiComposer } from "./components/composer";
import { ReportFrame } from "./components/report_frame";
import { extractHtmlBlocks } from "./artifacts";

export class AiChatAction extends Component {
static template = "codexoo.ChatAction";
static components = { AiMessageList, AiComposer };
static components = { AiMessageList, AiComposer, ReportFrame };
static props = ["*"];

setup() {
Expand All @@ -32,6 +34,9 @@ export class AiChatAction extends Component {
authUrl: "",
authDeviceCode: "",
authRaw: "",
// Artifact panel (right side): rendered chart/report HTML.
artifact: null,
artifactOpen: false,
});
this._authPoll = null;

Expand Down Expand Up @@ -139,6 +144,66 @@ export class AiChatAction extends Component {
const data = await this.rpc("/codexoo/messages", { session_id: id });
this.state.messages = data.messages;
this.state.running = data.state === "running";
// Surface this conversation's most recent chart/report on the right.
this.state.artifact = null;
this.state.artifactOpen = false;
this._captureLatestArtifact();
}

// ------------------------------------------------------------------
// Artifact panel
// ------------------------------------------------------------------
openArtifact(html) {
this.state.artifact = html;
this.state.artifactOpen = true;
}

closeArtifact() {
this.state.artifactOpen = false;
}

openArtifactNewTab() {
if (!this.state.artifact) {
return;
}
const blob = new Blob([this.state.artifact], { type: "text/html" });
const url = URL.createObjectURL(blob);
browser.open(url, "_blank", "noopener,noreferrer");
browser.setTimeout(() => URL.revokeObjectURL(url), 60000);
}

// The HTML to show for a message, or null if it carries none.
_messageArtifact(msg) {
if (!msg) {
return null;
}
if (msg.role === "report") {
return msg.body || null;
}
if (msg.role === "assistant") {
const blocks = extractHtmlBlocks(msg.body || "");
return blocks.length ? blocks[blocks.length - 1] : null;
}
return null;
}

// Auto-open the chart/report from a freshly streamed message.
_maybeCaptureArtifact(msg) {
const html = this._messageArtifact(msg);
if (html) {
this.openArtifact(html);
}
}

// Scan history (newest first) and open the latest artifact, if any.
_captureLatestArtifact() {
for (let i = this.state.messages.length - 1; i >= 0; i--) {
const html = this._messageArtifact(this.state.messages[i]);
if (html) {
this.openArtifact(html);
return;
}
}
}

_upsertMessage(msg) {
Expand All @@ -157,6 +222,7 @@ export class AiChatAction extends Component {
break;
case "message":
this._upsertMessage(p.message);
this._maybeCaptureArtifact(p.message);
break;
case "done":
this.state.running = false;
Expand Down
23 changes: 22 additions & 1 deletion codexoo/static/src/chat_action.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@

<!-- Main: messages + composer -->
<div class="o_ai_main d-flex flex-column flex-grow-1">
<AiMessageList messages="state.messages" running="state.running"/>
<AiMessageList messages="state.messages" running="state.running"
onOpenArtifact.bind="openArtifact"/>
<div t-if="state.running" class="px-2 pb-1">
<button class="btn btn-sm btn-link text-danger" t-on-click="onStop">
<i class="fa fa-stop me-1"/> Stop
Expand All @@ -81,6 +82,26 @@
<AiComposer onSend.bind="onSend" disabled="state.running"
sessionId="state.currentId"/>
</div>

<!-- Artifact panel: rendered charts/reports, on the far right -->
<div t-if="state.artifactOpen and state.artifact"
class="o_ai_artifact d-flex flex-column border-start">
<div class="o_ai_artifact_header d-flex align-items-center px-3 py-2 border-bottom">
<i class="fa fa-area-chart me-2"/>
<span class="fw-bold">Preview</span>
<button class="btn btn-sm btn-link ms-auto p-0 me-2"
title="Open in new tab" t-on-click="openArtifactNewTab">
<i class="fa fa-external-link"/>
</button>
<button class="btn btn-sm btn-link p-0" title="Close"
t-on-click="closeArtifact">
<i class="fa fa-times"/>
</button>
</div>
<div class="o_ai_artifact_body flex-grow-1 overflow-auto p-3">
<ReportFrame html="state.artifact"/>
</div>
</div>
</div>
</t>

Expand Down
56 changes: 50 additions & 6 deletions codexoo/static/src/components/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,72 @@
// Copyright 2026 CICDoo (https://cicdoo.com)
// SPDX-License-Identifier: LGPL-3.0-or-later OR LicenseRef-Codexoo-Commercial
// Dual-licensed: open source (LGPL-3, see LICENSE) or commercial (see COMMERCIAL_LICENSE.md).
import { Component } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { renderMarkdown } from "../markdown";
import { splitSegments } from "../artifacts";

export class AiMessage extends Component {
static template = "codexoo.Message";
static props = { message: Object };
static props = {
message: Object,
// Called with an HTML string to display it in the artifact panel.
onOpenArtifact: { type: Function, optional: true },
};

setup() {
// Tool calls are collapsed by default; this toggles the per-message view.
this.ui = useState({ toolsOpen: false });
}

get roleLabel() {
return { user: "You", assistant: "Assistant", error: "Error" }[
return { user: "You", assistant: "Assistant", report: "Report", error: "Error" }[
this.props.message.role
] || this.props.message.role;
}

// Render assistant/error markdown to HTML; keep user messages as plain text.
get bodyHtml() {
return renderMarkdown(this.props.message.body || "");
get isReport() {
return this.props.message.role === "report";
}

// Ordered render segments for non-user messages: markdown chunks (rendered
// to safe HTML) interleaved with embedded HTML chunks shown as a card that
// opens the artifact panel. A report-role message is a single HTML segment.
// User messages stay plain text and don't use this.
get segments() {
const body = this.props.message.body || "";
if (this.isReport) {
return [{ type: "html", html: body }];
}
const out = [];
for (const seg of splitSegments(body)) {
if (!seg.text.trim()) {
continue;
}
out.push(seg.type === "html"
? { type: "html", html: seg.text }
: { type: "md", html: renderMarkdown(seg.text) });
}
return out;
}

openArtifact(html) {
this.props.onOpenArtifact?.(html);
}

get toolCalls() {
return this.props.message.tool_calls || [];
}

toggleTools() {
this.ui.toolsOpen = !this.ui.toolsOpen;
}

// True while at least one tool call is still running.
get toolsBusy() {
return this.toolCalls.some(
(c) => c.status !== "done" && c.status !== "error");
}

get attachments() {
return this.props.message.attachments || [];
}
Expand Down
Loading
Loading