55from time import time
66from typing import Literal , NotRequired , TypedDict
77
8+ import disnake
89from disnake .ext .commands import command
910from yarl import URL
1011
@@ -78,20 +79,21 @@ def __init__(self, ara: Ara):
7879 "Authorization" : f"Bearer { Config .nvidia_api_key } " ,
7980 "Accept" : "application/json" ,
8081 }
81- self .context = defaultdict [int , list [NimPrompt ]](list )
82+ self .context = defaultdict [int , list [tuple [ int , NimPrompt ] ]](list )
8283
8384 instructions = Path ("resources/llm-instructions.md" ).read_text (encoding = "utf-8" )
8485 self .instructions = NimPrompt (role = "system" , content = instructions )
8586
8687 @command (brief = "Prompt LLM with text, replies and images" , help = HELP_TEXT , usage = "<prompt and/or media>" )
8788 async def ai (self , ctx : Context ):
88- prompt = self .ctx_to_prompt (ctx )
89- if not prompt :
89+ ctx .message .content = ctx .argument_only .strip ()
90+ nim_prompt = self .msg_to_prompt (ctx .message )
91+ if not nim_prompt :
9092 await ctx .send_help (ctx .command )
9193 return
9294
93- history = list ( filter ( None , map ( self .prune_expired_media , self . context [ ctx . channel . id ][ - 18 :])) )
94- messages = [self .instructions , * history , prompt ]
95+ history , reply_chain = await self .get_clean_history ( ctx )
96+ messages = [self .instructions , * history , * ( p for _ , p in reply_chain ), nim_prompt ]
9597
9698 payload = {
9799 "messages" : messages ,
@@ -112,28 +114,68 @@ async def ai(self, ctx: Context):
112114 logging .debug ("AI payload: %r\n AI response: %r" , payload , data )
113115
114116 answer : str = data ["choices" ][0 ]["message" ]["content" ]
115-
116117 ai_response = NimPrompt (role = "assistant" , content = answer )
117- self .context [ctx .channel .id ] = [self .instructions , * history [- 17 :], prompt , ai_response ]
118118
119119 if len (answer ) > (maxlen := 1997 ):
120120 answer = "." .join (answer [:maxlen ].rsplit ("." , maxsplit = 2 )[:- 1 ]) + "..."
121121
122- await ctx .reply (answer , mention_author = True )
122+ reply_msg = await ctx .reply (answer , mention_author = True )
123123
124- @staticmethod
125- def ctx_to_prompt (ctx : Context ) -> NimPrompt | None :
126- items : list [NimInput ] = []
124+ memory = self .context [ctx .channel .id ]
125+ memory .extend (reply_chain ) # TODO: Don't append existing messages
126+ memory .append ((ctx .message .id , nim_prompt ))
127+ memory .append ((reply_msg .id , ai_response ))
128+
129+ self .context [ctx .channel .id ] = memory [- 18 :]
130+
131+ log = "\n " .join (
132+ f"{ i } : { m ['content' ] if isinstance (m ['content' ], str ) else m ['content' ][0 ]['text' ]} "
133+ for i , m in self .context [ctx .channel .id ]
134+ )
135+ logging .info (f"\n { log } \n " )
136+
137+ async def get_clean_history (self , ctx : Context ) -> tuple [list [NimPrompt ], list [tuple [int , NimPrompt ]]]:
138+ raw_history = self .context [ctx .channel .id ][- 18 :]
139+ history : list [NimPrompt ] = []
140+ history_ids = set [int ]()
141+
142+ for msg_id , prompt in raw_history :
143+ if pruned := self .prune_expired_media (prompt ):
144+ history .append (pruned )
145+ history_ids .add (msg_id )
146+
147+ reply_chain : list [tuple [int , NimPrompt ]] = []
148+ current_msg = ctx .message
127149
128- if prompt := ctx .argument_only .strip ():
129- item = NimInputText (
130- type = NimInputType .TEXT ,
131- text = f"[{ ctx .author .id } |{ ctx .author .global_name or ctx .author .name } ]:{ prompt } " ,
132- )
150+ for _ in range (3 ):
151+ if not (ref := current_msg .reference ) or not (ref_msg_id := ref .message_id ) or ref in history_ids :
152+ break
153+
154+ try :
155+ ref_msg = ref .cached_message or await ctx .channel .fetch_message (ref_msg_id )
156+ except disnake .HTTPException :
157+ break
158+
159+ if ref_prompt := self .msg_to_prompt (ref_msg ):
160+ reply_chain .insert (0 , (ref_msg_id , ref_prompt ))
161+ history_ids .add (ref_msg_id )
162+
163+ current_msg = ref_msg
164+
165+ return history , reply_chain
166+
167+ def msg_to_prompt (self , msg : disnake .Message ) -> NimPrompt | None :
168+ if msg .author == self .ara .user :
169+ return NimPrompt (role = "assistant" , content = msg .content ) if msg .content else None
170+
171+ items : list [NimInput ] = []
172+ if msg .content :
173+ author_name = msg .author .global_name or msg .author .name
174+ item = NimInputText (type = NimInputType .TEXT , text = f"[{ msg .author .id } |{ author_name } ]:{ msg .content } " )
133175 items .append (item )
134176
135- for att in ctx . message .attachments :
136- if att .content_type .startswith ("image/" ):
177+ for att in msg .attachments :
178+ if att .content_type and att . content_type .startswith ("image/" ):
137179 item = NimInputImageUrl (type = NimInputType .IMAGE_URL , image_url = NimInputUrl (url = att .url ))
138180 items .append (item )
139181
@@ -144,7 +186,8 @@ def prune_expired_media(item: NimPrompt) -> NimPrompt | None:
144186 if isinstance (item ["content" ], str ):
145187 return item
146188
147- for idx , input_item in enumerate (item ["content" ]):
189+ for idx in range (len (item ["content" ]) - 1 , - 1 , - 1 ):
190+ input_item = item ["content" ][idx ]
148191 match input_item ["type" ]:
149192 case NimInputType .AUDIO_URL :
150193 url = input_item ["audio_url" ]["url" ]
0 commit comments