2424from 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+
2771class 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
0 commit comments