mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-20 12:34:21 +00:00
e9dd584792
* feat(api): support global API key from config.yaml (api.global_api_key) Accept a config-defined global API key anywhere a web-UI key is accepted (X-API-Key / Bearer), with no login session and no DB record. Useful for automated deployments and AI agents (HTTP API + MCP). Defaults to empty (disabled); does not require the lbk_ prefix. - templates/config.yaml: add api.global_api_key with security notes - service/apikey.py: verify_api_key checks global key first (constant-time) - docs/API_KEY_AUTH.md: document the global key + security guidance - tests: cover global-key match, prefix-free, fallback-to-db, disabled * feat(mcp): expose LangBot management as an MCP server at /mcp Add an MCP (Model Context Protocol) server so external AI agents can manage a LangBot instance. Reuses the same API-key auth as the HTTP API (including the config.yaml global API key). - pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated tools across system/bots/pipelines/models/knowledge/mcp-servers/skills - pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp requests with an API key, runs the streamable-HTTP session manager lifespan - controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task) - web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and client config; i18n for 8 locales - tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools) Tool surface is intentionally curated (not all ~25 route groups) to keep the agent surface small, safe, and maintainable. Extend deliberately. * feat(skills): add in-repo skills/ as the single source of truth Migrate the agent skills + QA/e2e test harness from the (now archived) langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills. Migrated: - langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup, langbot-skills-maintenance, langbot-eba-adapter-dev - the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/) New: - langbot-dev core backend + web development - langbot-deploy Docker/K8s deployment + config.yaml + global API key - langbot-mcp-ops operating the LangBot MCP server (/mcp) - langbot-space-ops operating the Space marketplace MCP server - src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json + bin/lbs) so the CLI works when nested inside the LangBot repo - README.md: unified skill catalog; skills.index.json regenerated Parity with source verified: bin/lbs validate + node test suite match the source repo (only the uncommitted .lbpkg build-artifact fixture differs). * docs(agents): document agent-facing surfaces + API/MCP/skills sync rule * docs(readme): add 'Built for AI Agents' section across all locales Highlight MCP server, in-repo skills (single source of truth), AGENTS.md sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace. * style(mcp): fix ruff format + prettier lint in MCP server and API panel * style(web): prettier format MCP i18n locale entries * docs(skills): note MCP instance control in dev/testing skills All development-guidance skills now point to the LangBot instance MCP server (/mcp) and the Space marketplace MCP server, reusing API keys.
417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
import {
|
|
bodyText,
|
|
clickFirstVisible,
|
|
countOccurrences,
|
|
gotoFrontend,
|
|
isLoginUrl,
|
|
} from "./langbot-e2e.mjs";
|
|
|
|
export const DEBUG_CHAT_FAILURE_SIGNALS = [
|
|
"Agent runner temporarily unavailable",
|
|
"All models failed during streaming setup",
|
|
"调用超时",
|
|
"超时",
|
|
];
|
|
|
|
export function minExpectedOccurrences(beforeText, expectedText, prompt) {
|
|
const beforeCount = countOccurrences(beforeText, expectedText);
|
|
return beforeCount + (String(prompt).includes(expectedText) ? 2 : 1);
|
|
}
|
|
|
|
export function latestExpectedLeafMatches(latestExpectedLeaf, prompt) {
|
|
return Boolean(latestExpectedLeaf)
|
|
&& latestExpectedLeaf !== prompt
|
|
&& !String(latestExpectedLeaf).includes(prompt);
|
|
}
|
|
|
|
export function findNewFailureSignal(beforeText, afterText, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) {
|
|
return failureSignals.find((signal) => countOccurrences(afterText, signal) > countOccurrences(beforeText, signal)) || "";
|
|
}
|
|
|
|
function findFailureSignalInText(text, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) {
|
|
return failureSignals.find((signal) => String(text || "").includes(signal)) || "";
|
|
}
|
|
|
|
function countExpectedInMessages(messages, expectedText) {
|
|
return messages
|
|
.filter((message) => message.role === "assistant")
|
|
.reduce((count, message) => count + countOccurrences(message.text, expectedText), 0);
|
|
}
|
|
|
|
function debugChatInput(page) {
|
|
return page
|
|
.locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]')
|
|
.last();
|
|
}
|
|
|
|
async function clickDebugChatTab(page) {
|
|
const tabByRole = page.getByRole("tab", { name: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first();
|
|
if (await tabByRole.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
await tabByRole.click();
|
|
return true;
|
|
}
|
|
|
|
const tabBySelector = page.locator('[role="tab"]').filter({ hasText: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first();
|
|
if (await tabBySelector.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
await tabBySelector.click();
|
|
return true;
|
|
}
|
|
|
|
return Boolean(await clickFirstVisible(page, ["Debug Chat", "调试聊天", "调试对话"], 2_000));
|
|
}
|
|
|
|
async function waitForDebugChatReady(page, timeout = 20_000) {
|
|
const input = debugChatInput(page);
|
|
const visible = await input.isVisible({ timeout }).catch(() => false);
|
|
if (!visible) {
|
|
return {
|
|
ready: false,
|
|
reason: "Debug Chat tab was clicked, but the Debug Chat input did not become visible.",
|
|
};
|
|
}
|
|
|
|
const enabled = await input.isEnabled({ timeout }).catch(() => false);
|
|
if (!enabled) {
|
|
return {
|
|
ready: false,
|
|
reason: "Debug Chat input is visible but disabled; WebSocket may not be connected.",
|
|
};
|
|
}
|
|
|
|
return { ready: true, reason: "" };
|
|
}
|
|
|
|
export function classifyDebugChatResult({
|
|
beforeText,
|
|
afterText,
|
|
expectedText,
|
|
prompt,
|
|
latestExpectedLeaf,
|
|
latestFailureLeaf,
|
|
beforeMessages = null,
|
|
afterMessages = null,
|
|
latestAssistantText = "",
|
|
failureSignals = DEBUG_CHAT_FAILURE_SIGNALS,
|
|
}) {
|
|
const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt);
|
|
const finalCount = countOccurrences(afterText, expectedText);
|
|
const failureText = findNewFailureSignal(beforeText, afterText, failureSignals);
|
|
const promptContainsExpected = String(prompt).includes(expectedText);
|
|
const hasMessageEvidence = Array.isArray(beforeMessages) && Array.isArray(afterMessages);
|
|
const beforeAssistantExpectedCount = hasMessageEvidence
|
|
? countExpectedInMessages(beforeMessages, expectedText)
|
|
: null;
|
|
const afterAssistantExpectedCount = hasMessageEvidence
|
|
? countExpectedInMessages(afterMessages, expectedText)
|
|
: null;
|
|
const assistantExpectedIncreased = hasMessageEvidence
|
|
? afterAssistantExpectedCount > beforeAssistantExpectedCount
|
|
: false;
|
|
|
|
if (hasMessageEvidence) {
|
|
const latestAssistantFailure = findFailureSignalInText(latestAssistantText, failureSignals);
|
|
if (latestAssistantFailure) {
|
|
return {
|
|
status: "fail",
|
|
reason: `Debug Chat displayed a known failure signal in the latest assistant message: ${latestAssistantFailure}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
failure_signal: latestAssistantFailure,
|
|
before_assistant_expected_count: beforeAssistantExpectedCount,
|
|
after_assistant_expected_count: afterAssistantExpectedCount,
|
|
};
|
|
}
|
|
if (assistantExpectedIncreased && String(latestAssistantText).includes(expectedText)) {
|
|
return {
|
|
status: "pass",
|
|
reason: `Expected text appeared in a new assistant message: ${expectedText}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
before_assistant_expected_count: beforeAssistantExpectedCount,
|
|
after_assistant_expected_count: afterAssistantExpectedCount,
|
|
};
|
|
}
|
|
if (failureText) {
|
|
return {
|
|
status: "fail",
|
|
reason: `Debug Chat displayed a known failure signal: ${failureText}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
failure_signal: failureText,
|
|
before_assistant_expected_count: beforeAssistantExpectedCount,
|
|
after_assistant_expected_count: afterAssistantExpectedCount,
|
|
};
|
|
}
|
|
return {
|
|
status: "fail",
|
|
reason: `Expected text did not appear in a new assistant message. Expected assistant occurrences to increase above ${beforeAssistantExpectedCount}, saw ${afterAssistantExpectedCount}.`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
before_assistant_expected_count: beforeAssistantExpectedCount,
|
|
after_assistant_expected_count: afterAssistantExpectedCount,
|
|
};
|
|
}
|
|
if (failureText) {
|
|
return {
|
|
status: "fail",
|
|
reason: `Debug Chat displayed a known failure signal: ${failureText}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
failure_signal: failureText,
|
|
before_assistant_expected_count: beforeAssistantExpectedCount,
|
|
after_assistant_expected_count: afterAssistantExpectedCount,
|
|
};
|
|
}
|
|
if (latestExpectedLeafMatches(latestExpectedLeaf, prompt) && finalCount >= minExpectedCount) {
|
|
return {
|
|
status: "pass",
|
|
reason: `Expected text appeared in the latest visible response leaf: ${expectedText}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
};
|
|
}
|
|
if (!promptContainsExpected && finalCount >= minExpectedCount) {
|
|
return {
|
|
status: "pass",
|
|
reason: `Expected text appeared enough times for user prompt plus bot response: ${expectedText}`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
};
|
|
}
|
|
return {
|
|
status: "fail",
|
|
reason: `Bot response did not appear. Expected ${minExpectedCount} occurrences of ${expectedText}, saw ${finalCount}.`,
|
|
min_expected_count: minExpectedCount,
|
|
final_count: finalCount,
|
|
};
|
|
}
|
|
|
|
export async function openPipelineDebugChat(page, { pipelineUrl, pipelineName, envHint = "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME" }) {
|
|
if (pipelineUrl) {
|
|
await page.goto(pipelineUrl, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
|
} else {
|
|
if (!pipelineName) {
|
|
return {
|
|
opened: false,
|
|
status: "blocked",
|
|
reason: `Set ${envHint} before running pipeline-debug-chat automation.`,
|
|
};
|
|
}
|
|
await gotoFrontend(page);
|
|
if (isLoginUrl(page.url())) {
|
|
return {
|
|
opened: false,
|
|
status: "blocked",
|
|
reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.",
|
|
};
|
|
}
|
|
const clickedPipelines = await clickFirstVisible(page, ["Pipelines", "流水线"], 4_000);
|
|
if (!clickedPipelines) {
|
|
return { opened: false, status: "fail", reason: "Could not find Pipelines navigation." };
|
|
}
|
|
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
|
const clickedPipeline = await clickFirstVisible(page, [pipelineName], 5_000);
|
|
if (!clickedPipeline) {
|
|
return { opened: false, status: "blocked", reason: `Could not find pipeline named ${pipelineName}.` };
|
|
}
|
|
}
|
|
|
|
if (isLoginUrl(page.url())) {
|
|
return {
|
|
opened: false,
|
|
status: "blocked",
|
|
reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.",
|
|
};
|
|
}
|
|
|
|
const clickedDebug = await clickDebugChatTab(page);
|
|
if (!clickedDebug) {
|
|
return { opened: false, status: "fail", reason: "Could not find the Debug Chat tab." };
|
|
}
|
|
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
|
const ready = await waitForDebugChatReady(page);
|
|
if (!ready.ready) {
|
|
return { opened: false, status: "fail", reason: ready.reason };
|
|
}
|
|
return { opened: true };
|
|
}
|
|
|
|
export async function latestVisibleLeafText(page, needles) {
|
|
return await page.evaluate((items) => {
|
|
const isVisible = (element) => {
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.visibility !== "hidden"
|
|
&& style.display !== "none"
|
|
&& rect.width > 0
|
|
&& rect.height > 0;
|
|
};
|
|
const leaves = [];
|
|
for (const element of document.body.querySelectorAll("*")) {
|
|
if (!isVisible(element)) continue;
|
|
const text = element.innerText?.trim();
|
|
if (!text || text.length > 4000) continue;
|
|
const visibleChildHasText = Array.from(element.children).some((child) => (
|
|
isVisible(child) && child.innerText?.trim()
|
|
));
|
|
if (visibleChildHasText) continue;
|
|
if (!items.some((needle) => text.includes(needle))) continue;
|
|
leaves.push(text);
|
|
}
|
|
return leaves.at(-1) || "";
|
|
}, needles);
|
|
}
|
|
|
|
export async function visibleDebugChatMessages(page) {
|
|
return await page.evaluate(() => {
|
|
const isVisible = (element) => {
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.visibility !== "hidden"
|
|
&& style.display !== "none"
|
|
&& rect.width > 0
|
|
&& rect.height > 0;
|
|
};
|
|
const classText = (element) => String(element.getAttribute("class") || "");
|
|
return Array.from(document.querySelectorAll("div.max-w-3xl"))
|
|
.filter((element) => isVisible(element))
|
|
.map((element) => {
|
|
const row = element.parentElement;
|
|
const text = element.innerText?.trim() || "";
|
|
const isUser = classText(element).includes("user-message-bubble")
|
|
|| classText(row).includes("justify-end");
|
|
return {
|
|
role: isUser ? "user" : "assistant",
|
|
text,
|
|
};
|
|
})
|
|
.filter((message) => message.text);
|
|
});
|
|
}
|
|
|
|
export async function waitForExpectedDebugChatText(page, { expectedText, minExpectedCount, timeoutMs }) {
|
|
await page.waitForFunction(
|
|
({ expected, min }) => {
|
|
return document.body.innerText.split(expected).length - 1 >= min;
|
|
},
|
|
{ expected: expectedText, min: minExpectedCount },
|
|
{ timeout: timeoutMs },
|
|
).catch(() => {});
|
|
}
|
|
|
|
export async function waitForDebugChatTextStable(page, { timeoutMs = 5_000, quietMs = 750 } = {}) {
|
|
const startedAt = Date.now();
|
|
let lastText = await bodyText(page);
|
|
let stableSince = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
await page.waitForTimeout(250);
|
|
const currentText = await bodyText(page);
|
|
if (currentText !== lastText) {
|
|
lastText = currentText;
|
|
stableSince = Date.now();
|
|
continue;
|
|
}
|
|
if (Date.now() - stableSince >= quietMs) return;
|
|
}
|
|
}
|
|
|
|
export async function attachDebugChatImage(page, imagePath) {
|
|
if (!imagePath) return { status: "not_required", reason: "" };
|
|
const input = page.locator('input[type="file"][accept*="image"], input[type="file"]').first();
|
|
if (!await input.count()) {
|
|
return { status: "fail", reason: "Could not find a Debug Chat image upload input." };
|
|
}
|
|
await input.setInputFiles(imagePath);
|
|
await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
|
|
return { status: "ready", reason: `Attached image fixture: ${imagePath}` };
|
|
}
|
|
|
|
export async function sendDebugChatPrompt(page, prompt, imagePath = "") {
|
|
const imageResult = await attachDebugChatImage(page, imagePath);
|
|
if (imageResult.status === "fail") return imageResult;
|
|
|
|
const input = debugChatInput(page);
|
|
const inputVisible = await input.isVisible({ timeout: 5_000 }).catch(() => false);
|
|
const inputEnabled = inputVisible && await input.isEnabled({ timeout: 10_000 }).catch(() => false);
|
|
if (!inputVisible || !inputEnabled) return false;
|
|
await input.fill(prompt).catch(async () => {
|
|
await input.click();
|
|
await input.pressSequentially(prompt);
|
|
});
|
|
const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500);
|
|
if (!clickedSend) await page.keyboard.press("Enter");
|
|
await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
export async function runDebugChatPrompt(page, { prompt, expectedText, responseTimeoutMs, imagePath = "", failureSignals = DEBUG_CHAT_FAILURE_SIGNALS }) {
|
|
const beforeText = await bodyText(page);
|
|
const beforeMessages = await visibleDebugChatMessages(page);
|
|
const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt);
|
|
const sent = await sendDebugChatPrompt(page, prompt, imagePath);
|
|
if (sent !== true) {
|
|
if (sent && typeof sent === "object" && typeof sent.reason === "string") return sent;
|
|
return { status: "fail", reason: "Could not find a Debug Chat text input." };
|
|
}
|
|
|
|
await waitForExpectedDebugChatText(page, {
|
|
expectedText,
|
|
minExpectedCount,
|
|
prompt,
|
|
timeoutMs: responseTimeoutMs,
|
|
});
|
|
await waitForDebugChatTextStable(page);
|
|
|
|
const afterText = await bodyText(page);
|
|
const afterMessages = await visibleDebugChatMessages(page);
|
|
const latestAssistantText = afterMessages.filter((message) => message.role === "assistant").at(-1)?.text || "";
|
|
const latestExpectedLeaf = await latestVisibleLeafText(page, [expectedText]);
|
|
const failureText = findNewFailureSignal(beforeText, afterText, failureSignals);
|
|
const latestFailureLeaf = failureText ? await latestVisibleLeafText(page, [failureText]) : "";
|
|
|
|
return classifyDebugChatResult({
|
|
beforeText,
|
|
afterText,
|
|
expectedText,
|
|
prompt,
|
|
latestExpectedLeaf,
|
|
latestFailureLeaf,
|
|
beforeMessages,
|
|
afterMessages,
|
|
latestAssistantText,
|
|
failureSignals,
|
|
});
|
|
}
|
|
|
|
export async function setDebugChatStreamOutput(page, desired) {
|
|
if (desired === null || desired === undefined) return { status: "not_required", reason: "" };
|
|
|
|
const streamSwitch = page.locator('[role="switch"]').first();
|
|
if (!await streamSwitch.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
return { status: "blocked", reason: "Debug Chat stream switch was not visible." };
|
|
}
|
|
if (!await streamSwitch.isEnabled({ timeout: 10_000 }).catch(() => false)) {
|
|
return { status: "blocked", reason: "Debug Chat stream switch was visible but disabled." };
|
|
}
|
|
|
|
const checked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true";
|
|
if (checked !== desired) {
|
|
await streamSwitch.click();
|
|
await page.waitForFunction(
|
|
({ selector, expected }) => document.querySelector(selector)?.getAttribute("aria-checked") === String(expected),
|
|
{ selector: '[role="switch"]', expected: desired },
|
|
{ timeout: 5_000 },
|
|
).catch(() => {});
|
|
}
|
|
|
|
const finalChecked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true";
|
|
if (finalChecked !== desired) {
|
|
return {
|
|
status: "fail",
|
|
reason: `Debug Chat stream switch did not reach requested state: ${desired ? "on" : "off"}.`,
|
|
};
|
|
}
|
|
return { status: "ready", reason: `Debug Chat stream switch is ${desired ? "on" : "off"}.` };
|
|
}
|