Merge pull request #2704 from ManishMadan2882/main

fix(react-widget): integrate with current contract
This commit is contained in:
Alex authored and GitHub committed 2026-08-31 11:36:03 +01:00
commit 3e12a6bf62
6 files changed
+855 -265

No files matched your search

+17 -3
View File
@@ -11,6 +11,7 @@ from application.api.answer.services.conversation_service import (
TERMINATED_RESPONSE_PLACEHOLDER,
)
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.attachments import AttachmentsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.message_events import MessageEventsRepository
@@ -357,10 +358,20 @@ class SubmitFeedback(Resource):
description="Submit feedback for a conversation",
)
def post(self):
data = request.get_json() or {}
decoded_token = request.decoded_token
# api_key callers (widgets) carry no JWT; resolve the key to its owner.
scoped_api_key = None
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
api_key = data.get("api_key")
agent = None
if api_key:
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_key(api_key)
if not agent:
return make_response(jsonify({"success": False}), 401)
decoded_token = {"sub": agent.get("user_id")}
scoped_api_key = api_key
required_fields = ["feedback", "conversation_id", "question_index"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
@@ -387,7 +398,10 @@ class SubmitFeedback(Resource):
with db_session() as conn:
repo = ConversationsRepository(conn)
conv = repo.get_any(data["conversation_id"], user_id)
if conv is None:
# A key may only rate conversations it created.
if conv is None or (
scoped_api_key and conv.get("api_key") != scoped_api_key
):
return make_response(
jsonify({"success": False, "message": "Not found"}), 404
)
File diff suppressed because it is too large. Load diff
@@ -11,6 +11,7 @@ interface FetchAnswerStreamingProps {
conversationId?: string | null;
apiHost?: string;
onEvent?: (event: MessageEvent) => void;
signal?: AbortSignal;
}
export interface FeedbackPayload {
@@ -31,13 +32,17 @@ export function fetchAnswerStreaming({
onEvent = () => {
console.log('Event triggered, but no handler provided.');
},
signal,
}: FetchAnswerStreamingProps): Promise<void> {
return new Promise<void>((resolve, reject) => {
const body = {
question: question,
history: JSON.stringify(history),
history: JSON.stringify(
history
.filter((item) => item.prompt && item.response)
.map(({ prompt, response }) => ({ prompt, response })),
),
conversation_id: conversationId,
model: 'default',
api_key: apiKey,
};
fetch(apiHost + '/stream', {
@@ -46,49 +51,61 @@ export function fetchAnswerStreaming({
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
})
.then((response) => {
if (!response.body) throw Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let counter = 0; // eslint-disable-line @typescript-eslint/no-unused-vars
let buffer = '';
const emit = (rawLine: string) => {
const line = rawLine.trim();
if (line === '') return;
if (!line.startsWith('data:')) return;
const payload = line.substring(5).trim();
if (payload === '' || payload === '[DONE]') return;
onEvent(new MessageEvent('message', { data: payload }));
};
const processStream = ({
done,
value,
}: ReadableStreamReadResult<Uint8Array>) => {
if (done) {
if (buffer.trim() !== '') emit(buffer);
buffer = '';
resolve();
return;
}
buffer += decoder.decode(value, { stream: true });
counter += 1;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) emit(line);
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (let line of lines) {
if (line.trim() == '') {
continue;
}
if (line.startsWith('data:')) {
line = line.substring(5);
}
const messageEvent = new MessageEvent('message', {
data: line,
});
onEvent(messageEvent); // handle each message
}
reader.read().then(processStream).catch(reject);
reader.read().then(processStream).catch(onReadError);
};
reader.read().then(processStream).catch(reject);
const onReadError = (error: unknown) => {
if (signal?.aborted || (error as Error)?.name === 'AbortError') {
resolve();
return;
}
reject(error);
};
reader.read().then(processStream).catch(onReadError);
})
.catch((error) => {
if (signal?.aborted || (error as Error)?.name === 'AbortError') {
resolve();
return;
}
console.error('Connection failed:', error);
reject(error);
});
@@ -14,6 +14,11 @@ declare module 'styled-components' {
};
/** Present only in SearchBar theme */
name?: string;
/** Gradient stops for the swept status text. */
shimmer?: {
base: string;
highlight: string;
};
/** Present only in DocsGPTWidget theme (always provided when these styled components render) */
dimensions?: {
size: string;
@@ -41,6 +46,12 @@ export interface Query {
sources?: { title: string; text: string; source: string }[];
conversationId?: string | null;
title?: string | null;
/** Accumulated from thought events. */
thought?: string;
/** Latest notice or running workflow node; drives the status line. */
notice?: string;
/** Tool names from tool_calls / tool_call. */
toolCalls?: string[];
}
export interface WidgetProps {
@@ -0,0 +1,50 @@
export interface StreamEvent {
type?: string;
[key: string]: unknown;
}
export interface ToolCallEvent {
action_name?: string;
tool_name?: string;
}
/** internal_search -> Internal Search */
export const prettifyName = (name: string): string =>
name
.split(/[_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
/**
* Status-line text for a running workflow node, or null when the node is
* scaffolding the user gains nothing from seeing. Keyed on node_type (a
* closed enum) rather than node_title, which is free text like "agent node".
*/
export const workflowStepLabel = (event: StreamEvent): string | null => {
if (event.status !== 'running') return null;
switch (event.node_type) {
case 'agent':
return 'Thinking…';
case 'code':
return 'Running code…';
case 'condition':
return 'Deciding next step…';
case 'start':
case 'end':
case 'state':
case 'note':
return null;
default:
return typeof event.node_title === 'string' && event.node_title
? `${prettifyName(event.node_title)}…`
: null;
}
};
export const toolNames = (calls: unknown): string[] => {
if (!Array.isArray(calls)) return [];
return calls
.map((call: ToolCallEvent | null) => call?.action_name ?? call?.tool_name)
.filter((name): name is string => Boolean(name));
};
+85
View File
@@ -769,3 +769,88 @@ class TestSubmitFeedbackHappy:
response = SubmitFeedback().post()
assert response.status_code == 400
@pytest.mark.unit
class TestSubmitFeedbackWithApiKey:
"""api_key callers carry no JWT."""
def _seed_agent_with_key(self, pg_conn, owner, key):
from application.storage.db.repositories.agents import AgentsRepository
return AgentsRepository(pg_conn).create(owner, "widget", "published", key=key)
def _post(self, app, pg_conn, payload):
from application.api.user.conversations.routes import SubmitFeedback
with _patch_conversations_db(pg_conn), app.test_request_context("/api/feedback", method="POST", json=payload):
from flask import request
request.decoded_token = None
return SubmitFeedback().post()
def test_valid_key_rates_its_own_conversation(self, app, pg_conn):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
owner, key = "owner-fb-key", "agent-key-ok"
self._seed_agent_with_key(pg_conn, owner, key)
repo = ConversationsRepository(pg_conn)
conv_id = str(repo.create(owner, name="via widget", api_key=key)["id"])
repo.append_message(conv_id, {"prompt": "p", "response": "r"})
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": key,
},
)
assert response.status_code == 200
fb = repo.get_messages(conv_id)[0].get("feedback")
assert fb and fb.get("text") == "like"
def test_key_cannot_rate_owner_conversation_it_did_not_create(self, app, pg_conn):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
owner, key = "owner-fb-scope", "agent-key-scope"
self._seed_agent_with_key(pg_conn, owner, key)
# Owner's conversation, not created with the key.
conv_id = _seed_conversation(pg_conn, owner, name="owner private")
ConversationsRepository(pg_conn).append_message(conv_id, {"prompt": "p", "response": "r"})
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": key,
},
)
assert response.status_code == 404
def test_unknown_key_is_unauthorized(self, app, pg_conn):
conv_id = _seed_conversation(pg_conn, "owner-fb-bad")
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": "no-such-key",
},
)
assert response.status_code == 401