enable simple chat

This commit is contained in:
2026-03-06 15:19:51 +08:00
parent da17f2b319
commit 9b3db40b94
3 changed files with 384 additions and 23 deletions

View File

@@ -16,6 +16,7 @@ import {
listGraphConfigs,
listPipelines,
stopPipeline,
streamAgentChatResponse,
updateMcpToolConfig,
upsertGraphConfig,
} from "./api/frontApis";
@@ -40,6 +41,12 @@ type EditableAgent = {
llmName: string;
};
type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
};
type ActiveTab = "agents" | "discussions" | "mcp";
type McpTransport = "streamable_http" | "sse" | "stdio";
type McpEntry = {
@@ -401,6 +408,10 @@ function buildAgentChatUrlBase(): string {
return `${baseUrl}/`;
}
function createConversationId(): string {
return `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function toEditable(
config: GraphConfigReadResponse,
draft: boolean
@@ -439,6 +450,11 @@ export default function App() {
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const [discussionMessages, setDiscussionMessages] = useState<ConversationMessageItem[]>([]);
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 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 = [
...draftAgents.map((d) => ({
id: d.id,
@@ -1069,8 +1167,8 @@ export default function App() {
</div>
<div className="agent-list">
{rows.map((row) => (
<div key={row.id} className="agent-item-row">
<button
key={row.id}
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
onClick={() => {
if (row.isDraft) {
@@ -1079,7 +1177,9 @@ export default function App() {
setEditor(selectedDraft);
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) {
selectExisting(item);
}
@@ -1093,6 +1193,18 @@ export default function App() {
</span>
<small>{row.graphId}</small>
</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}
</div>
@@ -1530,6 +1642,60 @@ export default function App() {
</section>
)}
</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>
);
}

View File

@@ -167,3 +167,105 @@ export async function getPipelineConversationMessages(
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;
}

View File

@@ -65,6 +65,12 @@ button:disabled {
gap: 8px;
}
.agent-item-row {
display: grid;
gap: 6px;
grid-template-columns: 1fr auto;
}
.agent-item {
align-items: flex-start;
display: flex;
@@ -109,6 +115,11 @@ button:disabled {
color: #5f6f82;
}
.agent-chat-button {
align-self: stretch;
min-width: 64px;
}
.content {
padding: 20px;
}
@@ -535,3 +546,85 @@ button:disabled {
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;
}