mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
Merge pull request #2704 from ManishMadan2882/main
fix(react-widget): integrate with current contract
This commit is contained in:
6 files changed
+855
-265
No files matched your search
@@ -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));
|
||||
};
|
||||
@@ -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
|
||||
Reference in new issue
Block a user