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
11 changes: 10 additions & 1 deletion backend/domain/_composed.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,20 @@ def connect(self, source: str, target: str) -> SceneEdge: ...

def remove_nodes(self, node_ids: list[str]) -> None: ...

# ConversationalOps' own, consumed by VisualOps' generated-image
# reply. Declared with the real signature, not a *args hedge: the
# implementation is now a sibling mixin the checker can read, so an
# inexact declaration here would be an incompatible-override error
# rather than the useful fiction it was while the body lived in
# SceneDocument itself.
def add_chat_node(
self, x: float, y: float, content: str, is_user: bool,
parent_id: str | None = None, *args: Any, **kwargs: Any,
parent_id: str | None = None,
content_parts: list[dict[str, Any]] | None = None,
) -> SceneNode: ...

def adopt_pending_system_prompt(self, root_id: str) -> SceneEdge | None: ...

def place_root(self, kind: str) -> tuple[float, float]: ...

def place_child(
Expand Down
1,113 changes: 12 additions & 1,101 deletions backend/domain/graph.py

Large diffs are not rendered by default.

314 changes: 314 additions & 0 deletions backend/domain/nodes_agent_runs.py

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions backend/domain/nodes_code_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
The mixin needs only what SceneDocumentParts already declares - `nodes`,
`connect`, `_counter` - so it inherits that and stays type-checkable in
isolation, exactly like its cross-cutting siblings.

Import posture, carried over with the code: nothing here imports from
graphlink_plugins.review_lens - every method below takes plain values,
already fetched and normalized by the dispatch layer, and only stores
them.
"""

from __future__ import annotations
Expand All @@ -25,6 +30,7 @@
from backend.domain.node_access import optional_node, require_node
from backend.domain.node_states import CodeReviewState


def _bundle_int(value: object) -> int:
"""Non-negative int from a fetch bundle, defaulting to 0.

Expand Down
235 changes: 235 additions & 0 deletions backend/domain/nodes_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""ContentOps - the SceneDocument methods for the document, thinking, html
and note node kinds.

A MIXIN, composed exactly once, by backend/domain/graph.py's
SceneDocument. Method bodies are relocated VERBATIM from graph.py;
only the class wrapper, its docstring and the imports are new, and the
methods are regrouped by kind rather than left in the order successive
increments happened to append them in.

See backend/domain/nodes_code_review.py's docstring for why the
per-kind method groups are being lifted out of SceneDocument at all.
"""

from __future__ import annotations

from backend.domain._composed import SceneDocumentParts
from backend.domain.model import (
HTML_TITLE_PREVIEW_LENGTH,
THINKING_TITLE_PREVIEW_LENGTH,
SceneError,
SceneNode,
)
from backend.domain.node_access import require_node
from backend.domain.node_states import DocumentState, HtmlState, NoteState


class ContentOps(SceneDocumentParts):
"""The plain content kinds: document, thinking, html and note.

Each stores text the user or an agent wrote and does nothing else with
it - no run, no thread, no render. Note is the one that may float free;
the other three require a parent, like every R3+ content kind.
"""

def add_document_node(
self,
x: float,
y: float,
title: str,
content: str,
attachment_kind: str,
parent_id: str,
*,
file_path: str = "",
mime_type: str = "",
duration_seconds: float | None = None,
byte_size: int | None = None,
preview_label: str = "",
) -> SceneNode:
"""R3.9's document-node equivalent of add_chat_node/add_code_node: a
real file-attachment node (a document or an audio file), for the
legacy DocumentNode / ChatScene.add_document_node pair. UNLIKE
chat/code, parent_id is REQUIRED here, not optional: read fresh from
graphlink_scene.py, add_document_node(title, content,
parent_user_node, ...) takes parent_user_node as a plain required
positional with no default, and unconditionally constructs a
DocumentConnectionItem(parent_user_node, node) - there is no `if
parent_id` guard around that connection the way chat/code have
around theirs - so a DocumentNode can never exist unparented.
Document nodes are also NOT branch points (same as code): there is
no delete_document_node; deletion goes entirely through the
existing generic remove_nodes.

The six attachment fields are stored verbatim - no title-preview
truncation (DocumentNode.title in the legacy app is just whatever
descriptive title/filename was passed in, confirmed by reading
DocumentNode.__init__: `self.title = title`, no slicing), and none
of the legacy view-layer formatting (byte-size/duration strings,
preview_label auto-fill, audio-preview suppression) happens here -
see DocumentState's own docstring (backend/domain/node_states.py)
for those exact rules.
"""
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
# Mirrors DocumentNode.__init__'s `(attachment_kind or
# "document").lower()` normalization - the attachment_kind param has
# no default in this signature (per spec), but an empty/None value
# still needs to fall back to "document" and casing still needs to
# normalize, since "audio" vs "Audio" is a real behavioral branch
# (metadata "Type" row, preview label, badge text all key off it).
normalized_kind = str(attachment_kind or "document").lower()
node = SceneNode(
id=node_id,
x=float(x),
y=float(y),
title=str(title),
kind="document",
content=str(content),
state=DocumentState(
attachment_kind=normalized_kind,
file_path=str(file_path),
mime_type=str(mime_type),
duration_seconds=duration_seconds,
byte_size=byte_size,
preview_label=str(preview_label),
),
)
self.nodes[node_id] = node
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def add_thinking_node(
self,
x: float,
y: float,
thinking_text: str,
parent_id: str,
) -> SceneNode:
"""R3.13's ThinkingNode equivalent of add_chat_node/add_code_node/
add_document_node: a real reasoning-panel node. Same as
add_document_node (and unlike chat/code), parent_id is REQUIRED, not
optional - a ThinkingNode never exists unparented - so this
unconditionally connects to its parent, no `if parent_id` guard.

Thinking text reuses the existing `content` field rather than a new
one - there is no separate thinking-text field. `is_docked` defaults
to False: a freshly-created thinking node is never pre-docked: dock()
is only ever invoked by explicit user action or on session-load
restore, never at construction time.

Thinking nodes are also NOT branch points (same as code/document):
there is no delete_thinking_node; deletion goes entirely through the
existing generic remove_nodes.
"""
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
title = str(thinking_text)[:THINKING_TITLE_PREVIEW_LENGTH] or "Thinking"
node = SceneNode(
id=node_id,
x=float(x),
y=float(y),
title=title,
kind="thinking",
content=str(thinking_text),
)
self.nodes[node_id] = node
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def add_html_node(
self,
x: float,
y: float,
html_content: str,
parent_id: str,
) -> SceneNode:
"""R3.17's HtmlViewNode equivalent of add_document_node/
add_thinking_node: a real raw-HTML-source node. Same as
add_document_node/add_thinking_node (and unlike chat/code), parent_id
is REQUIRED, not optional - an HtmlViewNode never exists unparented -
so this unconditionally connects to its parent, no `if parent_id`
guard.

The raw HTML source reuses the existing `content` field rather than a
new one - there is no separate html-content field, same reuse pattern
as R3.5's code text and R3.13's thinking text. The backend stores it
VERBATIM as an opaque string: it never parses, sanitizes, validates,
or otherwise interprets the HTML - that is the frontend's job (the
preview render is a 100% client-side action that never round-trips
here).

Html nodes are also NOT branch points (same as code/document/
thinking): there is no delete_html_node; deletion goes entirely
through the existing generic remove_nodes.
"""
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
title = str(html_content)[:HTML_TITLE_PREVIEW_LENGTH] or "HTML"
node = SceneNode(
id=node_id,
x=float(x),
y=float(y),
title=title,
kind="html",
content=str(html_content),
state=HtmlState(),
)
self.nodes[node_id] = node
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def set_html_splitter_state(self, node_id: str, value: float) -> None:
"""R6.3: persists an HtmlViewNode's draggable code/preview splitter
position. html kind only (SceneError otherwise), matching every
other kind-specific setter's guard pattern in this file (e.g.
resize_chart/toggle_frame_lock)."""
node = require_node(self.nodes, node_id, "html", HtmlState)
node.state.html_splitter_state = float(value)

# -- R6.1: notes -----------------------------------------------------------
#
# Legacy canvas decorations. Notes are free-floating markdown sticky-
# notes with no parent-required posture, unlike almost every R3+ content
# kind. (Their neighbours in that increment, frames and containers, are
# group nodes and live in backend/domain/groups.py.)

def add_note(
self,
x: float,
y: float,
*,
is_system_prompt: bool = False,
is_summary_note: bool = False,
) -> SceneNode:
"""A note's creation primitive. UNLIKE every R3+ content kind, no
parent is required or accepted - notes are free-floating, never
branch-point children (mirrors the legacy note widget, which the
canvas places directly, not via a ChatNode-anchored connection)."""
node_id = f"n{next(self._counter)}"
node = SceneNode(
id=node_id,
x=float(x),
y=float(y),
title="Note",
kind="note",
content="Add note...",
state=NoteState(
is_system_prompt=bool(is_system_prompt),
is_summary_note=bool(is_summary_note),
),
)
self.nodes[node_id] = node
return node

def set_note_content(self, node_id: str, content: str) -> None:
node = self.nodes.get(node_id)
if node is None:
raise SceneError(f"unknown node: {node_id}")
node.content = str(content)
Loading
Loading