enable simple chat
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
listGraphConfigs,
|
listGraphConfigs,
|
||||||
listPipelines,
|
listPipelines,
|
||||||
stopPipeline,
|
stopPipeline,
|
||||||
|
streamAgentChatResponse,
|
||||||
updateMcpToolConfig,
|
updateMcpToolConfig,
|
||||||
upsertGraphConfig,
|
upsertGraphConfig,
|
||||||
} from "./api/frontApis";
|
} from "./api/frontApis";
|
||||||
@@ -40,6 +41,12 @@ type EditableAgent = {
|
|||||||
llmName: string;
|
llmName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AgentChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ActiveTab = "agents" | "discussions" | "mcp";
|
type ActiveTab = "agents" | "discussions" | "mcp";
|
||||||
type McpTransport = "streamable_http" | "sse" | "stdio";
|
type McpTransport = "streamable_http" | "sse" | "stdio";
|
||||||
type McpEntry = {
|
type McpEntry = {
|
||||||
@@ -401,6 +408,10 @@ function buildAgentChatUrlBase(): string {
|
|||||||
return `${baseUrl}/`;
|
return `${baseUrl}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createConversationId(): string {
|
||||||
|
return `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toEditable(
|
function toEditable(
|
||||||
config: GraphConfigReadResponse,
|
config: GraphConfigReadResponse,
|
||||||
draft: boolean
|
draft: boolean
|
||||||
@@ -439,6 +450,11 @@ export default function App() {
|
|||||||
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
||||||
const [discussionMessages, setDiscussionMessages] = useState<ConversationMessageItem[]>([]);
|
const [discussionMessages, setDiscussionMessages] = useState<ConversationMessageItem[]>([]);
|
||||||
const [discussionLoading, setDiscussionLoading] = useState(false);
|
const [discussionLoading, setDiscussionLoading] = useState(false);
|
||||||
|
const [chatPipelineId, setChatPipelineId] = useState<string | null>(null);
|
||||||
|
const [chatConversationId, setChatConversationId] = useState<string>(createConversationId);
|
||||||
|
const [chatInput, setChatInput] = useState<string>("");
|
||||||
|
const [chatMessages, setChatMessages] = useState<AgentChatMessage[]>([]);
|
||||||
|
const [chatSending, setChatSending] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const configKeySet = useMemo(
|
const configKeySet = useMemo(
|
||||||
@@ -1037,6 +1053,88 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAgentChat(pipelineId: string): void {
|
||||||
|
setChatPipelineId(pipelineId);
|
||||||
|
setChatConversationId(createConversationId());
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput("");
|
||||||
|
setStatusMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAgentChat(): void {
|
||||||
|
setChatPipelineId(null);
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewAgentChatConversation(): void {
|
||||||
|
setChatConversationId(createConversationId());
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAgentChatMessage(): Promise<void> {
|
||||||
|
const pipelineId = (chatPipelineId || "").trim();
|
||||||
|
const message = chatInput.trim();
|
||||||
|
if (!pipelineId || !message || chatSending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authKey = runtimeFastApiKey.trim() || "dev-key";
|
||||||
|
const userMessage: AgentChatMessage = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: "user",
|
||||||
|
content: message,
|
||||||
|
};
|
||||||
|
const assistantMessageId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const assistantMessage: AgentChatMessage = {
|
||||||
|
id: assistantMessageId,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
setChatSending(true);
|
||||||
|
setChatInput("");
|
||||||
|
setChatMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||||
|
setStatusMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamAgentChatResponse({
|
||||||
|
appId: pipelineId,
|
||||||
|
sessionId: chatConversationId,
|
||||||
|
apiKey: authKey,
|
||||||
|
message,
|
||||||
|
onText: (text) => {
|
||||||
|
setChatMessages((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
content: text,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const messageText = (error as Error).message || "Chat request failed.";
|
||||||
|
setStatusMessage(messageText);
|
||||||
|
setChatMessages((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
content: `Error: ${messageText}`,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setChatSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
...draftAgents.map((d) => ({
|
...draftAgents.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -1069,8 +1167,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="agent-list">
|
<div className="agent-list">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
|
<div key={row.id} className="agent-item-row">
|
||||||
<button
|
<button
|
||||||
key={row.id}
|
|
||||||
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
|
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (row.isDraft) {
|
if (row.isDraft) {
|
||||||
@@ -1079,7 +1177,9 @@ export default function App() {
|
|||||||
setEditor(selectedDraft);
|
setEditor(selectedDraft);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = displayConfigItems.find((x) => makeAgentKey(x.pipeline_id) === row.id);
|
const item = displayConfigItems.find(
|
||||||
|
(x) => makeAgentKey(x.pipeline_id) === row.id
|
||||||
|
);
|
||||||
if (item) {
|
if (item) {
|
||||||
selectExisting(item);
|
selectExisting(item);
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1193,18 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<small>{row.graphId}</small>
|
<small>{row.graphId}</small>
|
||||||
</button>
|
</button>
|
||||||
|
{!row.isDraft ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-chat-button"
|
||||||
|
onClick={() => openAgentChat(row.label)}
|
||||||
|
disabled={busy || !row.isRunning}
|
||||||
|
title={row.isRunning ? "Chat with this agent" : "Start this agent to chat"}
|
||||||
|
>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 ? <p className="empty">No agents configured yet.</p> : null}
|
{rows.length === 0 ? <p className="empty">No agents configured yet.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1530,6 +1642,60 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{chatPipelineId ? (
|
||||||
|
<div className="chat-modal-overlay" role="dialog" aria-modal="true">
|
||||||
|
<section className="chat-modal">
|
||||||
|
<header className="chat-modal-header">
|
||||||
|
<div>
|
||||||
|
<strong>Chat: {chatPipelineId}</strong>
|
||||||
|
<small>conv_id: {chatConversationId}</small>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button type="button" onClick={startNewAgentChatConversation} disabled={chatSending}>
|
||||||
|
New Conv
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={closeAgentChat} disabled={chatSending}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="chat-modal-messages">
|
||||||
|
{chatMessages.length === 0 ? (
|
||||||
|
<p className="empty">Send a message to start this conversation.</p>
|
||||||
|
) : (
|
||||||
|
chatMessages.map((message) => (
|
||||||
|
<article
|
||||||
|
key={message.id}
|
||||||
|
className={`chat-modal-message ${message.role === "assistant" ? "assistant" : "user"}`}
|
||||||
|
>
|
||||||
|
<strong>{message.role === "assistant" ? "Agent" : "You"}</strong>
|
||||||
|
<p>{message.content || (chatSending && message.role === "assistant" ? "..." : "")}</p>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="chat-modal-input">
|
||||||
|
<textarea
|
||||||
|
value={chatInput}
|
||||||
|
onChange={(event) => setChatInput(event.target.value)}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
rows={3}
|
||||||
|
disabled={chatSending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sendAgentChatMessage().catch(() => undefined);
|
||||||
|
}}
|
||||||
|
disabled={chatSending || !chatInput.trim()}
|
||||||
|
>
|
||||||
|
Send (Stream)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,3 +167,105 @@ export async function getPipelineConversationMessages(
|
|||||||
return response.items || [];
|
return response.items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StreamAgentChatOptions = {
|
||||||
|
appId: string;
|
||||||
|
sessionId: string;
|
||||||
|
apiKey: string;
|
||||||
|
message: string;
|
||||||
|
onText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseErrorDetail(payload: unknown): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = (payload as { detail?: unknown }).detail;
|
||||||
|
return typeof detail === "string" && detail.trim() ? detail : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamAgentChatResponse(
|
||||||
|
options: StreamAgentChatOptions
|
||||||
|
): Promise<string> {
|
||||||
|
const { appId, sessionId, apiKey, message, onText } = options;
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/v1/apps/${encodeURIComponent(appId)}/sessions/${encodeURIComponent(sessionId)}/responses`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: [{ role: "user", content: message }],
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let messageText = `Request failed (${response.status})`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as unknown;
|
||||||
|
const detail = parseErrorDetail(payload);
|
||||||
|
if (detail) {
|
||||||
|
messageText = detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep fallback status-based message.
|
||||||
|
}
|
||||||
|
throw new Error(messageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Streaming response is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffered = "";
|
||||||
|
let latestText = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffered += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
let splitIndex = buffered.indexOf("\n\n");
|
||||||
|
while (splitIndex >= 0) {
|
||||||
|
const eventBlock = buffered.slice(0, splitIndex);
|
||||||
|
buffered = buffered.slice(splitIndex + 2);
|
||||||
|
splitIndex = buffered.indexOf("\n\n");
|
||||||
|
|
||||||
|
const lines = eventBlock.split("\n");
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line.startsWith("data:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payloadRaw = line.slice(5).trim();
|
||||||
|
if (!payloadRaw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payloadRaw);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextText =
|
||||||
|
typeof (payload as { output?: { text?: unknown } })?.output?.text === "string"
|
||||||
|
? ((payload as { output: { text: string } }).output.text as string)
|
||||||
|
: "";
|
||||||
|
if (nextText !== latestText) {
|
||||||
|
latestText = nextText;
|
||||||
|
onText(latestText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestText;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ button:disabled {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-item-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-item {
|
.agent-item {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -109,6 +115,11 @@ button:disabled {
|
|||||||
color: #5f6f82;
|
color: #5f6f82;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat-button {
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -535,3 +546,85 @@ button:disabled {
|
|||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-modal-overlay {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(16, 24, 40, 0.45);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d7e6f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 86vh;
|
||||||
|
max-width: 820px;
|
||||||
|
padding: 12px;
|
||||||
|
width: min(92vw, 820px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #dbe2ea;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-header small {
|
||||||
|
color: #687788;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-messages {
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid #d7e6f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 56vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-message {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe2ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-message.user {
|
||||||
|
border-left: 3px solid #4d7ef3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-message.assistant {
|
||||||
|
border-left: 3px solid #26a269;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-message p {
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-input {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-modal-input textarea {
|
||||||
|
border: 1px solid #c9d4e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user