Skip to content

Commit 88bcfdf

Browse files
yhbcode000claude
andcommitted
Add file processing, copy selection, and UI improvements
- Add file_processor utility for handling file attachments - Add copy_select screen for text selection/copying - Enhance context_node with file processing capabilities - Update node model and store with file-related fields - Improve branch tree and conversation UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 448b535 commit 88bcfdf

12 files changed

Lines changed: 1216 additions & 31 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ dependencies = [
99
"litellm>=1.50.0",
1010
"hydra-core>=1.3.0",
1111
"omegaconf>=2.3.0",
12+
"pdfplumber>=0.11.0",
13+
"python-docx>=1.1.0",
14+
"openpyxl>=3.1.0",
15+
"python-pptx>=1.0.0",
1216
]
1317

1418
[project.scripts]
@@ -28,7 +32,7 @@ packages = ["src/multiverse_note"]
2832
[tool.pytest.ini_options]
2933
testpaths = ["tests"]
3034
pythonpath = ["src"]
31-
addopts = "-p no:ament_copyright -p no:ament_flake8 -p no:ament_lint -p no:ament_pep257 -p no:ament_xmllint -p no:launch_testing -p no:launch_testing_ros"
35+
addopts = "-p no:ament_copyright -p no:ament_flake8 -p no:ament_lint -p no:ament_pep257 -p no:ament_xmllint -p no:launch_testing -p no:launch_ros"
3236

3337
[dependency-groups]
3438
dev = [

src/multiverse_note/models/node.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class ConversationNode:
1515
title: str = ""
1616
summary: str = ""
1717
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
18+
attachments: str = "" # JSON string of attachment metadata
1819

1920
def to_dict(self) -> dict[str, str]:
2021
"""Serialize to a flat dict suitable for Redis HSET."""
@@ -25,6 +26,7 @@ def to_dict(self) -> dict[str, str]:
2526
"title": self.title,
2627
"summary": self.summary,
2728
"timestamp": self.timestamp,
29+
"attachments": self.attachments,
2830
}
2931

3032
@classmethod
@@ -38,4 +40,5 @@ def from_dict(cls, id: int, data: dict[str, str]) -> ConversationNode:
3840
title=data.get("title", ""),
3941
summary=data.get("summary", ""),
4042
timestamp=data.get("timestamp", ""),
43+
attachments=data.get("attachments", ""),
4144
)

src/multiverse_note/nodes/context_node.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
from typing import Any
1011

1112
from multiverse_note.nodes.base import BaseNode
@@ -75,13 +76,32 @@ def _handle_user_input(self, payload: dict) -> None:
7576
if not content or self._current_branch_id is None:
7677
return
7778

79+
attachments = payload.get("attachments", "")
80+
image_data = payload.get("image_data", [])
81+
7882
# Store user message as a ConversationNode
7983
user_node = self._node_store.create(
8084
branch_id=self._current_branch_id,
8185
role="user",
8286
content=content,
87+
attachments=attachments,
8388
)
8489
self._branch_store.add_node(self._current_branch_id, user_node.id)
90+
91+
# Store image attachment data at correct indices matching the attachments list
92+
if image_data:
93+
try:
94+
att_list = json.loads(attachments) if attachments else []
95+
except (json.JSONDecodeError, TypeError):
96+
att_list = []
97+
image_idx = 0
98+
for i, att in enumerate(att_list):
99+
if att.get("content_type") == "image" and image_idx < len(image_data):
100+
self._node_store.set_attachment_data(
101+
user_node.id, i, image_data[image_idx].get("base64", ""),
102+
)
103+
image_idx += 1
104+
85105
self.logger.info("Stored user node %d on branch %d", user_node.id, self._current_branch_id)
86106

87107
# Assemble context and publish LLM request
@@ -95,13 +115,14 @@ def _handle_user_input(self, payload: dict) -> None:
95115
},
96116
})
97117

98-
def _assemble_context(self) -> list[dict[str, str]]:
99-
"""Build an ordered list of {role, content} messages for the LLM.
118+
def _assemble_context(self) -> list[dict[str, Any]]:
119+
"""Build an ordered list of messages for the LLM.
100120
101121
Includes conversation history from referenced branches (context refs)
102122
followed by the current branch's conversation.
123+
Supports multimodal messages (text + images) for litellm.
103124
"""
104-
messages: list[dict[str, str]] = []
125+
messages: list[dict[str, Any]] = []
105126

106127
if self._current_branch_id is None:
107128
return messages
@@ -124,14 +145,46 @@ def _assemble_context(self) -> list[dict[str, str]]:
124145
messages.extend(self._load_branch_messages(self._current_branch_id))
125146
return messages
126147

127-
def _load_branch_messages(self, branch_id: int) -> list[dict[str, str]]:
128-
"""Load all conversation nodes for a branch as {role, content} dicts."""
148+
def _load_branch_messages(self, branch_id: int) -> list[dict[str, Any]]:
149+
"""Load all conversation nodes for a branch as message dicts.
150+
151+
For nodes with image attachments, builds multimodal content arrays
152+
per litellm format.
153+
"""
129154
node_ids = self._branch_store.get_nodes(branch_id)
130-
messages: list[dict[str, str]] = []
155+
messages: list[dict[str, Any]] = []
131156
for nid in node_ids:
132157
node = self._node_store.get(nid)
133-
if node is not None:
134-
messages.append({"role": node.role, "content": node.content})
158+
if node is None:
159+
continue
160+
161+
# Check for image attachments
162+
if node.attachments:
163+
try:
164+
att_list = json.loads(node.attachments)
165+
except (json.JSONDecodeError, TypeError):
166+
att_list = []
167+
168+
image_parts = []
169+
for i, att in enumerate(att_list):
170+
if att.get("content_type") == "image":
171+
b64 = self._node_store.get_attachment_data(nid, i)
172+
if b64:
173+
mime = att.get("mime_type", "image/png")
174+
image_parts.append({
175+
"type": "image_url",
176+
"image_url": {"url": f"data:{mime};base64,{b64}"},
177+
})
178+
179+
if image_parts:
180+
content_parts: list[dict[str, Any]] = [
181+
{"type": "text", "text": node.content},
182+
]
183+
content_parts.extend(image_parts)
184+
messages.append({"role": node.role, "content": content_parts})
185+
continue
186+
187+
messages.append({"role": node.role, "content": node.content})
135188
return messages
136189

137190
# --- Branch event handling ---

src/multiverse_note/store/node_store.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def create(
2121
content: str,
2222
title: str = "",
2323
summary: str = "",
24+
attachments: str = "",
2425
) -> ConversationNode:
2526
"""Create a new conversation node and return it."""
2627
new_id = self._r.incr("counter:node")
@@ -31,6 +32,7 @@ def create(
3132
content=content,
3233
title=title,
3334
summary=summary,
35+
attachments=attachments,
3436
)
3537
self._r.hset(f"node:{new_id}", mapping=node.to_dict())
3638
logger.info("Created node %d (branch=%d, role=%s)", new_id, branch_id, role)
@@ -43,6 +45,14 @@ def get(self, node_id: int) -> ConversationNode | None:
4345
return None
4446
return ConversationNode.from_dict(node_id, data)
4547

48+
def set_attachment_data(self, node_id: int, index: int, data: str) -> None:
49+
"""Store base64 attachment data for a node."""
50+
self._r.set(f"node:{node_id}:attachment:{index}", data)
51+
52+
def get_attachment_data(self, node_id: int, index: int) -> str | None:
53+
"""Retrieve base64 attachment data for a node."""
54+
return self._r.get(f"node:{node_id}:attachment:{index}")
55+
4656
def add_tag(self, node_id: int, tag_id: int) -> None:
4757
"""Associate a tag with a node."""
4858
self._r.sadd(f"node:{node_id}:tags", str(tag_id))

0 commit comments

Comments
 (0)