Skip to content
Open
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
25 changes: 25 additions & 0 deletions src/agent_manager/api/routes/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
ConversationSummary,
CreateConversationRequest,
CreateConversationResponse,
MessageFeedbackRequest,
MessageFeedbackResponse,
MessageOut,
PaginatedConversationsResponse,
SendMessageRequest,
Expand Down Expand Up @@ -84,6 +86,7 @@ async def list_messages(
content=message.content,
status=message.status,
created_at=message.created_at,
feedback=message.feedback,
)
for message in messages
]
Expand All @@ -105,6 +108,28 @@ async def get_usage(
)


@router.post(
"/conversations/{conversation_id}/messages/{message_id}/feedback",
response_model=MessageFeedbackResponse,
)
async def set_message_feedback(
conversation_id: str,
message_id: str,
body: MessageFeedbackRequest,
service: Service,
caller: Caller,
) -> MessageFeedbackResponse:
with as_http_error():
updated = await service.set_message_feedback(
conversation_id, message_id, body.feedback, caller
)
if updated is None:
from fastapi import HTTPException

raise HTTPException(status_code=404, detail="message not found")
return MessageFeedbackResponse(message_id=updated.message_id, feedback=updated.feedback)


@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
conversation_id: str,
Expand Down
10 changes: 10 additions & 0 deletions src/agent_manager/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ class MessageOut(BaseModel):
content: str
status: str
created_at: datetime
feedback: str | None = None


class MessageFeedbackRequest(BaseModel):
feedback: str = Field(..., pattern="^(thumbs_up|thumbs_down)$")


class MessageFeedbackResponse(BaseModel):
message_id: str
feedback: str | None = None


class SendMessageRequest(BaseModel):
Expand Down
15 changes: 15 additions & 0 deletions src/agent_manager/api/static/widget/api/AgentChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ export class AgentChatClient {
};
}

async setMessageFeedback(
conversationId: string,
messageId: string,
feedback: "thumbs_up" | "thumbs_down",
): Promise<{ message_id: string; feedback: string | null }> {
const response = await this.request(
`/conversations/${conversationId}/messages/${messageId}/feedback`,
{
method: "POST",
body: JSON.stringify({ feedback }),
},
);
return await response.json();
}

async *streamMessage(
conversationId: string,
message: string,
Expand Down
65 changes: 63 additions & 2 deletions src/agent_manager/api/static/widget/react/AgentChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
CopyIcon,
HistoryIcon,
SquarePenIcon,
ThumbsDownIcon,
ThumbsUpIcon,
XIcon,
} from "lucide-react";
import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -71,6 +73,7 @@ const toEntries = (message: ChatMessage): MessageEntry[] => {
runId: message.run_id ?? undefined,
role: message.role === "user" ? "user" : "ai",
text: message.content,
feedback: message.feedback,
};
return message.role === "user" && message.status === "cancelled"
? [entry, { id: newId(), role: "ai", text: "", status: "cancelled" }]
Expand Down Expand Up @@ -709,13 +712,17 @@ export function AgentChatApp({
<ChatMessage
key={entry.id}
entry={entry}
conversationId={activeId}
onApproval={(approval, decision) =>
void decideApproval(activeId, entry, approval, decision)
}
onCancelApproval={(approval) =>
void cancelApproval(activeId, entry.id, approval)
}
onEdit={() => editMessage(entry)}
onFeedback={(messageId, feedback) =>
void conversation.setMessageFeedback(activeId, messageId, feedback)
}
editable={canEdit}
/>
))}
Expand Down Expand Up @@ -797,15 +804,19 @@ function Launcher({

function ChatMessage({
entry,
conversationId,
onApproval,
onCancelApproval,
onEdit,
onFeedback,
editable,
}: {
entry: MessageEntry;
conversationId?: string;
onApproval: (approval: PendingApproval, decision: ApprovalDecision) => void;
onCancelApproval: (approval: PendingApproval) => void;
onEdit: () => void;
onFeedback: (messageId: string, feedback: "thumbs_up" | "thumbs_down") => void;
editable: boolean;
}) {
const from = entry.role === "user" ? "user" : "assistant";
Expand Down Expand Up @@ -869,7 +880,7 @@ function ChatMessage({
<MessageResponse>{entry.text}</MessageResponse>
</MessageContent>
) : null}
{entry.text.trim() ? <MessageActions text={entry.text} /> : null}
{entry.text.trim() ? <MessageActions text={entry.text} feedback={entry.feedback} messageId={entry.messageId} conversationId={conversationId} onFeedback={onFeedback} /> : null}
</>
)}
</Message>
Expand Down Expand Up @@ -957,10 +968,60 @@ function ThinkingDots() {
);
}

function MessageActions({ text }: { text: string }) {
function MessageActions({
text,
feedback,
messageId,
conversationId,
onFeedback,
}: {
text: string;
feedback?: "thumbs_up" | "thumbs_down";
messageId?: string;
conversationId?: string;
onFeedback?: (messageId: string, feedback: "thumbs_up" | "thumbs_down") => void;
}) {
const handleThumbsUp = useCallback(() => {
if (!messageId || !conversationId || !onFeedback) return;
const next = feedback === "thumbs_up" ? null : "thumbs_up";
if (next) {
void onFeedback(messageId, next);
}
}, [messageId, conversationId, onFeedback, feedback]);

const handleThumbsDown = useCallback(() => {
if (!messageId || !conversationId || !onFeedback) return;
const next = feedback === "thumbs_down" ? null : "thumbs_down";
if (next) {
void onFeedback(messageId, next);
}
}, [messageId, conversationId, onFeedback, feedback]);

return (
<div className="msg-actions">
<CopyButton text={text} />
{messageId && conversationId && onFeedback ? (
<div className="feedback-actions">
<button
aria-label="Thumbs up"
aria-pressed={feedback === "thumbs_up"}
className={`msg-action feedback-up${feedback === "thumbs_up" ? " active" : ""}`}
onClick={handleThumbsUp}
type="button"
>
<ThumbsUpIcon aria-hidden />
</button>
<button
aria-label="Thumbs down"
aria-pressed={feedback === "thumbs_down"}
className={`msg-action feedback-down${feedback === "thumbs_down" ? " active" : ""}`}
onClick={handleThumbsDown}
type="button"
>
<ThumbsDownIcon aria-hidden />
</button>
</div>
) : null}
</div>
);
}
Expand Down
13 changes: 13 additions & 0 deletions src/agent_manager/api/static/widget/react/useConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export interface Conversation {
listThreads(limit?: number, cursor?: string | null): Promise<PaginatedThreads>;
switchTo(conversationId: string): void;
startNew(): void;
setMessageFeedback(
conversationId: string,
messageId: string,
feedback: "thumbs_up" | "thumbs_down",
): Promise<{ message_id: string; feedback: string | null }>;
}

/** A stored conversation the server will not serve us: gone (404), or owned by
Expand Down Expand Up @@ -199,6 +204,12 @@ export function useConversation(
[endpoint],
);

const setMessageFeedback = useCallback(
(conversationId: string, messageId: string, feedback: "thumbs_up" | "thumbs_down") =>
client.setMessageFeedback(conversationId, messageId, feedback),
[client],
);

return useMemo(
() => ({
peekId,
Expand All @@ -213,6 +224,7 @@ export function useConversation(
listThreads,
switchTo,
startNew,
setMessageFeedback,
}),
[
peekId,
Expand All @@ -227,6 +239,7 @@ export function useConversation(
listThreads,
switchTo,
startNew,
setMessageFeedback,
],
);
}
2 changes: 2 additions & 0 deletions src/agent_manager/api/static/widget/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface ChatMessage {
content: string;
status: string;
created_at?: string;
feedback?: "thumbs_up" | "thumbs_down";
}

export type BudgetSeverity = "normal" | "warning" | "critical";
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface MessageEntry {
approvalSubmitting?: boolean;
approvalCancelling?: boolean;
approvalError?: string;
feedback?: "thumbs_up" | "thumbs_down";
}

export interface PendingApproval {
Expand Down
10 changes: 10 additions & 0 deletions src/agent_manager/application/conversation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ async def usage(self, conversation_id: str, principal: Principal) -> TokenBudget
used = await self._repository.get_token_usage(conversation_id)
return TokenBudgetUsage.from_totals(used, self._max_tokens)

async def set_message_feedback(
self,
conversation_id: str,
message_id: str,
feedback: str,
principal: Principal,
) -> ConversationMessage | None:
await self._authorize(conversation_id, principal)
return await self._repository.update_message_feedback(message_id, feedback)

async def list_conversations(
self, principal: Principal, page: PageRequest | None = None
) -> Page[ConversationSession]:
Expand Down
1 change: 1 addition & 0 deletions src/agent_manager/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class ConversationMessage:
status: str = "succeeded"
error_type: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
feedback: str | None = None


@dataclass(frozen=True)
Expand Down
12 changes: 12 additions & 0 deletions src/agent_manager/domain/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,15 @@ async def add_message(self, conversation_id: str, role: Role, content: str) -> N
async def list_messages(self, conversation_id: str, limit: int | None = None) -> list[Message]:
"""Messages oldest-first. With `limit`, the most recent `limit`, still
oldest-first."""

@abstractmethod
async def update_message_feedback(
self,
message_id: str,
feedback: str,
) -> ConversationMessage | None:
"""Persist a πŸ‘/πŸ‘Ž vote on a single assistant message.

Returns the updated message, or ``None`` if the message does not exist.
"""
...
14 changes: 14 additions & 0 deletions src/agent_manager/infrastructure/persistence/memory_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,20 @@ async def list_messages(self, conversation_id: str, limit: int | None = None) ->
msgs = await self.list_conversation_messages(conversation_id, limit)
return [Message(role=m.role, content=m.content, created_at=m.created_at) for m in msgs]

async def update_message_feedback(
self,
message_id: str,
feedback: str,
) -> ConversationMessage | None:
for _sid, messages in list(self._messages.items()):
for index, message in enumerate(messages):
if message.message_id == message_id:
metadata = {**message.metadata, "feedback": feedback}
updated = replace(message, feedback=feedback, metadata=metadata)
messages[index] = updated
return updated
return None

async def get_snapshot(self, session_id: str) -> ConversationSnapshot | None:
return self._snapshots.get(session_id)

Expand Down
27 changes: 25 additions & 2 deletions src/agent_manager/infrastructure/persistence/sql_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,23 @@ async def list_messages(self, conversation_id: str, limit: int | None = None) ->
Message(role=row.role, content=row.content, created_at=row.created_at) for row in rows
]

async def update_message_feedback(
self,
message_id: str,
feedback: str,
) -> ConversationMessage | None:
async with self._sessions() as session:
row = await session.get(ConversationMessageRow, message_id)
if row is None:
return None
metadata = dict(row.metadata_json or {})
metadata["feedback"] = feedback
row.metadata_json = metadata
session.add(row)
await session.commit()
await session.refresh(row)
return _message(row)

async def get_snapshot(self, session_id: str) -> ConversationSnapshot | None:
async with self._sessions() as session:
row = await session.get(ConversationSnapshotRow, session_id)
Expand Down Expand Up @@ -601,6 +618,9 @@ def _session(row: ConversationSessionRow) -> ConversationSession:


def _message_row(message: ConversationMessage) -> ConversationMessageRow:
metadata = dict(message.metadata)
if message.feedback is not None:
metadata["feedback"] = message.feedback
return ConversationMessageRow(
message_id=message.message_id,
session_id=message.session_id,
Expand All @@ -621,12 +641,13 @@ def _message_row(message: ConversationMessage) -> ConversationMessageRow:
latency_ms=message.latency_ms,
status=message.status,
error_type=message.error_type,
metadata_json=dict(message.metadata),
metadata_json=metadata,
created_at=message.created_at,
)


def _message(row: ConversationMessageRow) -> ConversationMessage:
metadata = dict(row.metadata_json or {})
return ConversationMessage(
message_id=row.message_id,
session_id=row.session_id,
Expand All @@ -647,7 +668,8 @@ def _message(row: ConversationMessageRow) -> ConversationMessage:
latency_ms=row.latency_ms,
status=row.status,
error_type=row.error_type,
metadata=dict(row.metadata_json or {}),
metadata=metadata,
feedback=metadata.get("feedback"),
created_at=ensure_utc(row.created_at) or row.created_at,
)

Expand All @@ -666,6 +688,7 @@ def _message_json(row: ConversationMessageRow) -> dict[str, Any]:
"status": row.status,
"created_at": (ensure_utc(row.created_at) or row.created_at).isoformat(),
"metadata": dict(row.metadata_json or {}),
"feedback": (row.metadata_json or {}).get("feedback"),
}


Expand Down
Loading
Loading