Skip to content

Commit cdc3124

Browse files
committed
feat: support multimodal chat inputs and outputs
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
1 parent 39fc722 commit cdc3124

4 files changed

Lines changed: 146 additions & 33 deletions

File tree

ex_app/lib/agent.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
from ex_app.lib.signature import verify_signature
1818
from ex_app.lib.signature import add_signature
1919
from ex_app.lib.graph import AgentState, get_graph
20-
from ex_app.lib.nc_model import model
20+
from ex_app.lib.nc_model import (
21+
model,
22+
MULTIMODAL_INTERACTION,
23+
extract_text_content,
24+
extract_file_ids,
25+
build_multimodal_content,
26+
)
2127
from ex_app.lib.tools import get_tools
2228
from ex_app.lib.memorysaver import MemorySaver
2329
from ex_app.lib.jsonplus import JsonPlusSerializer
@@ -102,9 +108,11 @@ async def react(
102108
nc: AsyncNextcloudApp,
103109
stream_output: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
104110
):
105-
safe_tools, dangerous_tools = await get_tools(nc)
106-
111+
multimodal = task.get('type') == MULTIMODAL_INTERACTION
107112
model.bind_nextcloud(nc)
113+
model.multimodal = multimodal
114+
115+
safe_tools, dangerous_tools = await get_tools(nc)
108116

109117
tools = dangerous_tools + safe_tools
110118

@@ -188,7 +196,13 @@ async def call_model(
188196
else:
189197
new_input = None
190198
else:
191-
new_input = {"messages": [("user", task['input']['input'])]}
199+
input_attachments = task['input'].get('input_attachments') or []
200+
user_text = task['input']['input']
201+
if multimodal and input_attachments:
202+
user_content = build_multimodal_content(user_text, [int(file_id) for file_id in input_attachments])
203+
new_input = {"messages": [HumanMessage(content=user_content)]}
204+
else:
205+
new_input = {"messages": [("user", user_text)]}
192206

193207
snapshot_messages = state_snapshot.values.get('messages', [])
194208
last_message: AIMessage = AIMessage("")
@@ -252,9 +266,12 @@ async def report_stream_state(force: bool = False):
252266
if state_snapshot.next == ('dangerous_tools', ):
253267
actions = json.dumps(last_message.tool_calls)
254268

255-
return {
256-
'output': last_message.content,
269+
result = {
270+
'output': extract_text_content(last_message.content),
257271
'actions': actions,
258272
'conversation_token': export_conversation(checkpointer),
259273
'sources': source_list,
260274
}
275+
if multimodal:
276+
result['output_attachments'] = extract_file_ids(last_message.content)
277+
return result

ex_app/lib/main.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@
2424
from ex_app.lib.agent import react
2525
from ex_app.lib.logger import log
2626
from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware
27-
from ex_app.lib.provider import provider
27+
from ex_app.lib.provider import provider, multimodal_provider
2828
from ex_app.lib.tools import get_categories
2929

30+
PROVIDERS = [provider, multimodal_provider]
31+
PROVIDER_IDS = [p.id for p in PROVIDERS]
32+
TASK_TYPES = [p.task_type for p in PROVIDERS]
33+
3034
from contextvars import ContextVar
3135
from gettext import translation
3236
from fastmcp import FastMCP
@@ -113,7 +117,8 @@ async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str:
113117
# NOTE: `user` is unavailable on this step, so all NC API calls that require it will fail as unauthorized.
114118
await log(nc, LogLvl.INFO, f"enabled={enabled}")
115119
if enabled:
116-
await nc.providers.task_processing.register(provider)
120+
for p in PROVIDERS:
121+
await nc.providers.task_processing.register(p)
117122
app_enabled.set()
118123
await log(nc, LogLvl.WARNING, f"App enabled: {nc.app_cfg.app_name}")
119124

@@ -125,7 +130,8 @@ async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str:
125130
await nc.appconfig_ex.set_value('tool_status', json.dumps(pref_settings))
126131

127132
else:
128-
await nc.providers.task_processing.unregister(provider.id)
133+
for p in PROVIDERS:
134+
await nc.providers.task_processing.unregister(p.id)
129135
app_enabled.clear()
130136
await log(nc, LogLvl.WARNING, f"App disabled: {nc.app_cfg.app_name}")
131137
# In case of an error, a non-empty short string should be returned, which will be shown to the NC administrator.
@@ -142,7 +148,7 @@ async def background_thread_task():
142148
continue
143149

144150
try:
145-
response = await nc.providers.task_processing.next_task([provider.id], [provider.task_type])
151+
response = await nc.providers.task_processing.next_task(PROVIDER_IDS, TASK_TYPES)
146152
if not response or not 'task' in response:
147153
async with NUM_RUNNING_TASKS_LOCK:
148154
no_tasks_running = NUM_RUNNING_TASKS == 0
@@ -162,7 +168,14 @@ async def background_thread_task():
162168
task = response["task"]
163169
await log(nc, LogLvl.INFO, 'New Task incoming')
164170
await log(nc, LogLvl.DEBUG, str(task))
165-
await log(nc, LogLvl.INFO, str({'input': task['input']['input'], 'confirmation': task['input']['confirmation'], 'conversation_token': '<skipped>', 'memories': task['input'].get('memories', None)}))
171+
await log(nc, LogLvl.INFO, str({
172+
'type': task.get('type'),
173+
'input': task['input']['input'],
174+
'confirmation': task['input']['confirmation'],
175+
'conversation_token': '<skipped>',
176+
'memories': task['input'].get('memories', None),
177+
'input_attachments': task['input'].get('input_attachments', None),
178+
}))
166179
tg.create_task(handle_task(task, nc))
167180

168181
NUM_RUNNING_TASKS_LOCK = asyncio.Lock()

ex_app/lib/nc_model.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,50 @@
2424
from ex_app.lib.logger import log
2525

2626

27+
TEXT_CHAT_WITH_TOOLS = "core:text2text:chatwithtools"
28+
MULTIMODAL_CHAT_WITH_TOOLS = "core:text2text:multimodal-chatwithtools"
29+
MULTIMODAL_INTERACTION = "core:contextagent:multimodal-interaction"
30+
31+
32+
def extract_text_content(content: Any) -> str:
33+
"""Extract plain text from a string or multimodal content-part list."""
34+
if isinstance(content, str):
35+
return content
36+
if isinstance(content, list):
37+
parts = []
38+
for part in content:
39+
if isinstance(part, dict) and part.get("type") == "text":
40+
parts.append(part.get("text", ""))
41+
elif isinstance(part, str):
42+
parts.append(part)
43+
return "".join(parts)
44+
return ""
45+
46+
47+
def extract_file_ids(content: Any) -> list[int]:
48+
"""Extract file IDs from a multimodal content-part list."""
49+
if not isinstance(content, list):
50+
return []
51+
file_ids = []
52+
for part in content:
53+
if isinstance(part, dict) and part.get("type") == "file" and "file_id" in part:
54+
file_ids.append(int(part["file_id"]))
55+
return file_ids
56+
57+
58+
def build_multimodal_content(text: str, file_ids: list[int] | None = None, task_id: int | None = None) -> list[dict[str, Any]] | str:
59+
"""Build multimodal content parts, or plain text when there are no files."""
60+
if not file_ids:
61+
return text
62+
content: list[dict[str, Any]] = [
63+
({"type": "file", "file_id": file_id} if task_id is None else {"type": "file", "file_id": file_id, "ocp_task_id": task_id})
64+
for file_id in file_ids
65+
]
66+
if text:
67+
content.append({"type": "text", "text": text})
68+
return content
69+
70+
2771
class Task(BaseModel):
2872
id: int
2973
status: str
@@ -45,6 +89,7 @@ class ChatWithNextcloud(BaseChatModel):
4589
TOOL_OUTPUT_MAX_LENGTH: int = 2000
4690
POLL_WAIT_TIME: int = 5
4791
STREAMING_POLL_WAIT_TIME: int = 1
92+
multimodal: bool = False
4893

4994
def _generate(self, messages: list[BaseMessage], stop: Optional[list[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any):
5095
raise Exception("Use _agenerate instead")
@@ -77,6 +122,8 @@ def _build_task_input(self, messages: list[BaseMessage]) -> dict[str, typing.Any
77122
task_input['input'] = ''
78123
task_input['tool_message'] = []
79124
task_input['tools'] = json.dumps(self.tools)
125+
if self.multimodal:
126+
task_input['input_attachments'] = []
80127

81128
history = []
82129
for i, message in enumerate(messages):
@@ -87,9 +134,13 @@ def _build_task_input(self, messages: list[BaseMessage]) -> dict[str, typing.Any
87134
history.append(json.dumps(msg))
88135
elif message.type == 'human':
89136
if len(messages)-1 != i:
137+
# Earlier turns keep file parts in history content.
90138
history.append(json.dumps({"role": "human", "content": message.content}))
91139
else:
92-
task_input['input'] = message.content
140+
# Current turn: text → input, files → input_attachments.
141+
task_input['input'] = extract_text_content(message.content)
142+
if self.multimodal:
143+
task_input['input_attachments'] = extract_file_ids(message.content)
93144
elif message.type == 'tool':
94145
content = message.content
95146
age = len(messages) - 1 - i
@@ -116,14 +167,16 @@ async def _schedule_task(self, task_input: dict[str, typing.Any], prefer_streami
116167

117168
await log(nc, LogLvl.DEBUG, task_input)
118169

170+
task_type = MULTIMODAL_CHAT_WITH_TOOLS if self.multimodal else TEXT_CHAT_WITH_TOOLS
171+
119172
i = 0
120173
while i < 20:
121174
try:
122175
response = await nc.ocs(
123176
"POST",
124177
"/ocs/v1.php/taskprocessing/schedule",
125178
json={
126-
"type": "core:text2text:chatwithtools",
179+
"type": task_type,
127180
"appId": "context_agent",
128181
"input": task_input,
129182
"preferStreaming": prefer_streaming,
@@ -180,7 +233,6 @@ def _task_output_text(self, task: Task) -> str | None:
180233
return None
181234
output = task.output.get('output')
182235
return output if isinstance(output, str) else None
183-
184236
def _raw_task_tool_calls(self, task: Task) -> list[dict[str, typing.Any]]:
185237
if not isinstance(task.output, dict):
186238
return []
@@ -237,11 +289,15 @@ def _task_to_message(self, task: Task) -> AIMessage:
237289
if not isinstance(task.output, dict) or "output" not in task.output:
238290
raise Exception('"output" key not found in Nextcloud TaskProcessing task result')
239291

292+
output_text = task.output['output']
293+
output_attachments = task.output.get('output_attachments', [])
294+
content = build_multimodal_content(output_text, output_attachments, task.id)
295+
240296
tool_calls, invalid_tool_calls = self._task_tool_calls(task)
241297
if len(tool_calls) > 0 or len(invalid_tool_calls) > 0:
242-
message = AIMessage(task.output['output'], tool_calls=tool_calls, invalid_tool_calls=invalid_tool_calls)
298+
message = AIMessage(content, tool_calls=tool_calls, invalid_tool_calls=invalid_tool_calls)
243299
else:
244-
message = AIMessage(task.output['output'])
300+
message = AIMessage(content)
245301

246302
return message
247303

@@ -345,7 +401,21 @@ async def _astream(
345401
yield ChatGenerationChunk(message=AIMessageChunk(content=final_delta))
346402

347403
tool_calls, invalid_tool_calls = self._task_tool_calls(task)
348-
if len(tool_calls) > 0 or len(invalid_tool_calls) > 0:
404+
output_attachments = []
405+
if isinstance(task.output, dict):
406+
output_attachments = task.output.get('output_attachments') or []
407+
408+
if output_attachments:
409+
# Append file parts after streamed text so LangChain merges them
410+
# into the final AIMessage content (attachments are not streamed).
411+
yielded_chunk = True
412+
content = [{"type": "file", "file_id": int(file_id), "ocp_task_id": task.id} for file_id in output_attachments]
413+
yield ChatGenerationChunk(message=AIMessageChunk(
414+
content=content,
415+
tool_calls=tool_calls,
416+
invalid_tool_calls=invalid_tool_calls,
417+
))
418+
elif len(tool_calls) > 0 or len(invalid_tool_calls) > 0:
349419
yielded_chunk = True
350420
yield ChatGenerationChunk(message=AIMessageChunk(content='', tool_calls=tool_calls, invalid_tool_calls=invalid_tool_calls))
351421

ex_app/lib/provider.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
22
# SPDX-License-Identifier: AGPL-3.0-or-later
3-
from nc_py_api.ex_app.providers.task_processing import TaskProcessingProvider, TaskType, ShapeDescriptor, ShapeType
3+
from nc_py_api.ex_app.providers.task_processing import TaskProcessingProvider, ShapeDescriptor, ShapeType
44

55

6+
_optional_output_shape = [
7+
ShapeDescriptor(
8+
name="sources",
9+
description="Used tools",
10+
shape_type=ShapeType.LIST_OF_TEXTS
11+
)
12+
]
13+
14+
_optional_input_shape = [
15+
ShapeDescriptor(
16+
name="memories",
17+
description="Injected memories",
18+
shape_type=ShapeType.LIST_OF_TEXTS
19+
)
20+
]
21+
622
provider = TaskProcessingProvider(
723
id='context_agent:agent',
824
name='ContextAgent Provider',
925
task_type='core:contextagent:interaction',
1026
expected_runtime=60,
11-
optional_output_shape= [
12-
ShapeDescriptor(
13-
name="sources",
14-
description="Used tools",
15-
shape_type=ShapeType.LIST_OF_TEXTS
16-
)
17-
],
18-
optional_input_shape= [
19-
ShapeDescriptor(
20-
name="memories",
21-
description="Injected memories",
22-
shape_type=ShapeType.LIST_OF_TEXTS
23-
)
24-
]
25-
)
27+
optional_output_shape=_optional_output_shape,
28+
optional_input_shape=_optional_input_shape,
29+
)
30+
31+
multimodal_provider = TaskProcessingProvider(
32+
id='context_agent:agent_multimodal',
33+
name='ContextAgent Multimodal Provider',
34+
task_type='core:contextagent:multimodal-interaction',
35+
expected_runtime=60,
36+
optional_output_shape=_optional_output_shape,
37+
optional_input_shape=_optional_input_shape,
38+
)

0 commit comments

Comments
 (0)