feat: implement HTML GenUI component and register custom markdown tags (#8712)

* feat: implement HTML GenUI component and register custom markdown tags

* fix: expand bot message content width

* feat: add per-request ChatUI flags

* feat: refine ChatUI HTML GenUI prompt for clarity and token efficiency
This commit is contained in:
Soulter
2026-07-18 18:06:26 +08:00
committed by GitHub
parent 45f4e666e9
commit e47e5af9c4
23 changed files with 748 additions and 70 deletions
+13 -6
View File
@@ -21,6 +21,7 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.astr_agent_run_util import AgentRunner
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.astr_main_agent_resources import (
CHATUI_INLINE_GENUI_SYSTEM_PROMPT,
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
LIVE_MODE_SYSTEM_PROMPT,
LLM_SAFETY_MODE_SYSTEM_PROMPT,
@@ -488,6 +489,12 @@ async def _ensure_persona_and_skills(
event: AstrMessageEvent,
) -> None:
"""Ensure persona and skills are applied to the request's system prompt or user prompt."""
if req.system_prompt is None:
req.system_prompt = ""
if event.get_extra("enable_inline_genui"):
req.system_prompt += CHATUI_INLINE_GENUI_SYSTEM_PROMPT
if not req.conversation:
return
@@ -507,16 +514,16 @@ async def _ensure_persona_and_skills(
event, extract_persona_custom_error_message_from_persona(persona)
)
if req.system_prompt is None:
req.system_prompt = ""
if persona:
# Inject persona system prompt
if prompt := persona["prompt"]:
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
req.contexts[:0] = begin_dialogs
elif use_webchat_special_default:
elif (
use_webchat_special_default
and event.get_extra("enable_default_system_prompt") is not False
):
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# Inject skills prompt
@@ -986,9 +993,9 @@ async def _decorate_llm_request(
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
quote_images_already_captioned = False
if req.conversation:
await _ensure_persona_and_skills(req, cfg, plugin_context, event)
await _ensure_persona_and_skills(req, cfg, plugin_context, event)
if req.conversation:
if img_cap_prov_id and req.image_urls and not main_provider_supports_image:
await _ensure_img_caption(
event,
+18 -1
View File
@@ -54,11 +54,28 @@ CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
"move toward structure, insight, or guidance.\n"
"You listen more than you speak, respect uncertainty, avoid forcing quick conclusions or grand narratives, "
"and prefer clear, restrained language over unnecessary emotional embellishment. At your core, you value "
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps."
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps. "
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
)
CHATUI_INLINE_GENUI_SYSTEM_PROMPT = (
"\n\n[ChatUI HTML GenUI]\n"
"When user asks you to create, prototype, preview, or modify a visual HTML UI, "
"output the runnable HTML inside exactly one `<html-genui>...</html-genui>` block. "
'You may add a short optional title on the opening tag, for example `<html-genui title="Dashboard mockup">`. '
"Do not wrap the block in Markdown code fences. Put complete, self-contained HTML/CSS/JavaScript inside the tag, "
"including `<style>` and `<script>` when needed. Prefer responsive layouts that fit a chat iframe. "
"For revisions, output the full updated `<html-genui>` block instead of a diff. "
"Only use this block when an HTML UI preview is useful; otherwise answer normally. "
"Use the least tokens possible to achieve the goal. "
"No need to write <title> or <meta> tags. "
'Avoid "AI slop" UI: no purple/blue gradients, glassmorphism, emoji-as-icons, '
'or cookie-cutter "hero + 3-column cards" layouts. '
"Follow the specific design direction (e.g. Swiss, editorial, brutalist) before writing code. "
"Prefer restraint: Apple-style-like; fewer cards, borders, and shadows; build hierarchy through spacing and typography, not decoration."
)
LIVE_MODE_SYSTEM_PROMPT = (
"You are in a real-time conversation. "
"Speak like a real person, casual and natural. "
@@ -0,0 +1,30 @@
from typing import Any
WEBCHAT_REQUEST_FLAG_DEFAULTS = {
"enable_inline_genui": True,
"enable_default_system_prompt": True,
"enable_streaming": True,
}
def resolve_webchat_request_flags(payload: dict[str, Any]) -> dict[str, bool]:
"""Resolve supported WebChat flags with legacy top-level fallbacks.
A boolean value in ``flags`` has the highest priority, followed by the
legacy top-level field, then the server default.
Args:
payload: Incoming WebChat request or queue payload.
Returns:
A complete mapping of supported WebChat flags.
"""
raw_flags = payload.get("flags")
flags = raw_flags if isinstance(raw_flags, dict) else {}
resolved: dict[str, bool] = {}
for key, default in WEBCHAT_REQUEST_FLAG_DEFAULTS.items():
value = flags.get(key)
if not isinstance(value, bool):
value = payload.get(key)
resolved[key] = value if isinstance(value, bool) else default
return resolved
@@ -24,6 +24,7 @@ from .message_parts_helper import (
message_chain_to_storage_message_parts,
parse_webchat_message_parts,
)
from .request_flags import resolve_webchat_request_flags
from .webchat_event import WebChatMessageEvent
from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
@@ -255,13 +256,14 @@ class WebChatAdapter(Platform):
if isinstance(raw_message, tuple) and len(raw_message) >= 3:
payload = raw_message[2]
if isinstance(payload, dict):
flags = resolve_webchat_request_flags(payload)
message_event.set_extra("flags", flags)
for key, value in flags.items():
message_event.set_extra(key, value)
message_event.set_extra(
"selected_provider", payload.get("selected_provider")
)
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra(
"enable_streaming", payload.get("enable_streaming", True)
)
message_event.set_extra("action_type", payload.get("action_type"))
message_event.set_extra(
"llm_checkpoint_id", payload.get("llm_checkpoint_id")
+9
View File
@@ -116,10 +116,17 @@ class ChatMessagePatchRequest(OpenModel):
content: dict[str, Any]
class ChatFlags(BaseModel):
enable_inline_genui: bool = True
enable_default_system_prompt: bool = True
enable_streaming: bool = True
class ChatMessageRegenerateRequest(OpenModel):
selected_provider: str | None = None
selected_model: str | None = None
enable_streaming: bool | None = None
flags: ChatFlags | None = None
class ChatThreadCreateRequest(OpenModel):
@@ -133,6 +140,7 @@ class ChatThreadMessageRequest(OpenModel):
selected_provider: str | None = None
selected_model: str | None = None
enable_streaming: bool | None = None
flags: ChatFlags | None = None
class CronJobRequest(OpenModel):
@@ -198,6 +206,7 @@ class OpenApiChatRequest(OpenModel):
config_name: str | None = None
platform_id: str | None = None
enable_streaming: bool | None = None
flags: ChatFlags | None = None
class ImMessageRequest(OpenModel):
+7 -4
View File
@@ -22,6 +22,9 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
strip_message_parts_path_fields,
webchat_message_parts_have_content,
)
from astrbot.core.platform.sources.webchat.request_flags import (
resolve_webchat_request_flags,
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.active_event_registry import active_event_registry
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -1091,7 +1094,7 @@ class ChatService:
session_id = post_data.get("session_id", post_data.get("conversation_id"))
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True)
flags = resolve_webchat_request_flags(post_data)
platform_history_id = post_data.get("_platform_history_id") or "webchat"
thread_selected_text = post_data.get("_thread_selected_text")
@@ -1155,7 +1158,7 @@ class ChatService:
"message": message_parts,
"selected_provider": selected_provider,
"selected_model": selected_model,
"enable_streaming": enable_streaming,
"flags": flags,
"message_id": message_id,
"llm_checkpoint_id": llm_checkpoint_id,
"thread_selected_text": thread_selected_text,
@@ -1547,7 +1550,7 @@ class ChatService:
return {
"session_id": thread.thread_id,
"message": data.get("message", []),
"enable_streaming": data.get("enable_streaming", True),
"flags": resolve_webchat_request_flags(data),
"selected_provider": data.get("selected_provider"),
"selected_model": data.get("selected_model"),
"_platform_history_id": "webchat_thread",
@@ -1785,7 +1788,7 @@ class ChatService:
return {
"session_id": session_id,
"message": source_user_record.content.get("message", []),
"enable_streaming": data.get("enable_streaming", True),
"flags": resolve_webchat_request_flags(data),
"selected_provider": data.get("selected_provider"),
"selected_model": data.get("selected_model"),
"_skip_user_history": True,
@@ -23,6 +23,9 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
strip_message_parts_path_fields,
webchat_message_parts_have_content,
)
from astrbot.core.platform.sources.webchat.request_flags import (
resolve_webchat_request_flags,
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
@@ -516,7 +519,7 @@ class LiveChatService:
selected_tts_provider = message.get("selected_tts_provider")
persona_prompt = message.get("persona_prompt")
show_reasoning = message.get("show_reasoning")
enable_streaming = message.get("enable_streaming", True)
flags = resolve_webchat_request_flags(message)
if not isinstance(payload, list):
await self.send_chat_payload(
@@ -568,7 +571,7 @@ class LiveChatService:
"selected_tts_provider": selected_tts_provider,
"persona_prompt": persona_prompt,
"show_reasoning": show_reasoning,
"enable_streaming": enable_streaming,
"flags": flags,
"message_id": message_id,
"llm_checkpoint_id": llm_checkpoint_id,
},
@@ -16,6 +16,9 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
strip_message_parts_path_fields,
webchat_message_parts_have_content,
)
from astrbot.core.platform.sources.webchat.request_flags import (
resolve_webchat_request_flags,
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from astrbot.dashboard.services.api_key_service import ApiKeyService
@@ -371,7 +374,7 @@ class OpenApiService:
message_id = str(post_data.get("message_id") or uuid4())
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True)
flags = resolve_webchat_request_flags(post_data)
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
try:
@@ -384,7 +387,7 @@ class OpenApiService:
"message": message_parts,
"selected_provider": selected_provider,
"selected_model": selected_model,
"enable_streaming": enable_streaming,
"flags": flags,
"message_id": message_id,
},
)
@@ -67,6 +67,24 @@ export type BotRegistrationRequest = {
export type action = 'start' | 'poll';
/**
* Per-request ChatUI feature flags. A value here takes priority over its legacy top-level field, followed by the documented default.
*/
export type ChatFlags = {
/**
* Inject the inline HTML GenUI system prompt for this request.
*/
enable_inline_genui?: boolean;
/**
* Allow the ChatUI default system prompt when no persona overrides it.
*/
enable_default_system_prompt?: boolean;
/**
* Enable streaming model output for this request. This value takes priority over the legacy top-level enable_streaming field.
*/
enable_streaming?: boolean;
};
export type ChatMessagePatchRequest = {
content: {
[key: string]: unknown;
@@ -76,7 +94,12 @@ export type ChatMessagePatchRequest = {
export type ChatMessageRegenerateRequest = {
selected_provider?: string;
selected_model?: string;
/**
* Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
* @deprecated
*/
enable_streaming?: boolean;
flags?: ChatFlags;
};
export type ChatProjectRequest = {
@@ -104,7 +127,12 @@ export type ChatRequest = {
config_name?: string;
selected_provider?: string;
selected_model?: string;
/**
* Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
* @deprecated
*/
enable_streaming?: boolean;
flags?: ChatFlags;
/**
* Internal WebUI flag for edit/regenerate flows.
*/
@@ -141,7 +169,12 @@ export type ChatThreadMessageRequest = {
message: (string | Array<MessagePart>);
selected_provider?: string;
selected_model?: string;
/**
* Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
* @deprecated
*/
enable_streaming?: boolean;
flags?: ChatFlags;
};
export type CommandPatchRequest = {
+1
View File
@@ -1464,6 +1464,7 @@ async function handleRegenerateMessage(
message,
selection?.providerId || "",
selection?.modelName || "",
enableStreaming.value,
);
}
+1 -1
View File
@@ -1011,7 +1011,7 @@ defineExpose({
<style scoped>
.input-area {
padding: 12px 16px 0;
padding: 0 16px;
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
@@ -420,8 +420,6 @@
import { computed, nextTick, reactive, ref } from "vue";
import axios from "axios";
import { fileApi } from "@/api/v1";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import RegenerateMenu, {
type RegenerateModelSelection,
} from "@/components/chat/RegenerateMenu.vue";
@@ -431,12 +429,13 @@ import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ThreadNode from "@/components/chat/message_list_comps/ThreadNode.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import {
CHAT_MARKDOWN_CUSTOM_TAGS,
registerChatMarkdownComponents,
} from "@/components/chat/chatMarkdownComponents";
import {
attachmentName,
attachmentPresentation,
@@ -501,15 +500,11 @@ const emit = defineEmits<{
openRefs: [refs: unknown];
}>();
setCustomComponents("chat-message", {
ref: RefNode,
thread: ThreadNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
registerChatMarkdownComponents();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const customMarkdownTags = ["ref"];
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
const downloadingFiles = ref(new Set<string>());
const imagePreview = reactive({ visible: false, url: "" });
const refsSidebarOpen = ref(false);
@@ -912,6 +907,12 @@ function formatDuration(seconds: number) {
max-width: min(760px, 82%);
}
.from-bot .message-stack {
flex: 1 1 0;
min-width: 0;
max-width: 760px;
}
.from-user .message-stack {
align-items: flex-end;
max-width: 60%;
+12 -9
View File
@@ -277,18 +277,18 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from "vue";
import axios from "axios";
import {
CHAT_MARKDOWN_CUSTOM_TAGS,
registerChatMarkdownComponents,
} from "@/components/chat/chatMarkdownComponents";
import { fileApi } from "@/api/v1";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
attachmentName,
attachmentPresentation,
@@ -320,13 +320,10 @@ const props = withDefaults(
},
);
setCustomComponents("chat-message", {
ref: RefNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
registerChatMarkdownComponents();
const { tm } = useModuleI18n("features/chat");
const customMarkdownTags = ["ref"];
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
const downloadingFiles = ref(new Set<string>());
const messageListRoot = ref<HTMLElement | null>(null);
const imagePreview = reactive({ visible: false, url: "" });
@@ -638,6 +635,12 @@ function formatDuration(seconds: number) {
max-width: min(760px, 82%);
}
.from-bot .message-stack {
flex: 1 1 0;
min-width: 0;
max-width: 760px;
}
.from-user .message-stack {
align-items: flex-end;
max-width: 60%;
@@ -207,16 +207,16 @@ import {
ref,
} from "vue";
import { chatApi, configRouteApi, fileApi } from "@/api/v1";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import ChatInput from "@/components/chat/ChatInput.vue";
import {
CHAT_MARKDOWN_CUSTOM_TAGS,
registerChatMarkdownComponents,
} from "@/components/chat/chatMarkdownComponents";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
attachmentName,
attachmentPresentation,
@@ -240,10 +240,7 @@ const props = withDefaults(defineProps<{ configId?: string | null }>(), {
configId: "default",
});
setCustomComponents("chat-message", {
ref: RefNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
registerChatMarkdownComponents();
const { tm } = useModuleI18n("features/chat");
const customizer = useCustomizerStore();
@@ -258,7 +255,7 @@ const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const imagePreview = reactive({ visible: false, url: "" });
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const customMarkdownTags = ["ref"];
const customMarkdownTags = CHAT_MARKDOWN_CUSTOM_TAGS;
const {
stagedFiles,
@@ -548,6 +545,12 @@ function closeImage() {
max-width: 88%;
}
.from-bot .message-stack {
flex: 1 1 0;
min-width: 0;
max-width: 760px;
}
.from-user .message-stack {
max-width: 70%;
}
@@ -685,22 +688,6 @@ function closeImage() {
background: rgb(var(--v-theme-background));
}
.standalone-composer::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -32px;
height: 32px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0),
rgb(var(--v-theme-background))
);
}
.standalone-composer :deep(.input-area) {
border-top: 0;
}
@@ -61,6 +61,7 @@ import { fetchWithAuth } from "@/api/http";
import {
appendPlain,
appendReasoningPart,
buildChatRequestFlags,
extractReasoningText,
finishToolCall,
hasPlainText,
@@ -160,7 +161,7 @@ async function send() {
},
body: JSON.stringify({
message: [{ type: "plain", text }],
enable_streaming: true,
flags: buildChatRequestFlags(),
}),
signal: abort.signal,
});
@@ -0,0 +1,17 @@
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import HtmlGenUiNode from "@/components/chat/message_list_comps/HtmlGenUiNode.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ThreadNode from "@/components/chat/message_list_comps/ThreadNode.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
export const CHAT_MARKDOWN_CUSTOM_TAGS: string[] = ["ref", "html-genui"];
export function registerChatMarkdownComponents() {
setCustomComponents("chat-message", {
ref: RefNode,
thread: ThreadNode,
"html-genui": HtmlGenUiNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
}
@@ -0,0 +1,276 @@
<template>
<div class="html-genui-node" :class="{ 'is-dark': isDark, 'is-loading': isLoading }">
<div class="html-genui-header">
<div class="html-genui-title">{{ panelTitle }}</div>
<div class="html-genui-toggle" role="tablist" aria-label="HTML GenUI view">
<button
class="html-genui-toggle-button"
:class="{ active: viewMode === 'preview' }"
type="button"
role="tab"
:aria-selected="viewMode === 'preview'"
@click="viewMode = 'preview'"
>
Preview
</button>
<button
class="html-genui-toggle-button"
:class="{ active: viewMode === 'source' }"
type="button"
role="tab"
:aria-selected="viewMode === 'source'"
@click="viewMode = 'source'"
>
Source
</button>
</div>
</div>
<iframe
v-if="viewMode === 'preview'"
class="html-genui-frame"
:srcdoc="renderedSrcdoc"
:sandbox="sandboxPolicy"
title="Generated HTML UI preview"
loading="lazy"
></iframe>
<pre v-else class="html-genui-source"><code>{{ htmlContent }}</code></pre>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
const RENDER_THROTTLE_MS = 500;
const sandboxPolicy =
"allow-forms allow-modals allow-pointer-lock allow-popups allow-scripts";
const props = defineProps<{
node?: {
attrs?: Array<[string, string]>;
content?: string;
raw?: string;
loading?: boolean;
} | null;
loading?: boolean;
isDark?: boolean;
title?: string;
}>();
const renderedSrcdoc = ref("");
const viewMode = ref<"preview" | "source">("preview");
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let lastRenderAt = 0;
const htmlContent = computed(() =>
stripHtmlGenUiWrapper(String(props.node?.content || props.node?.raw || "")),
);
const isLoading = computed(() => Boolean(props.loading || props.node?.loading));
const isDark = computed(() => Boolean(props.isDark));
const panelTitle = computed(
() => props.title?.trim() || attrValue("title") || "HTML UI",
);
watch(
[htmlContent, isLoading, isDark],
() => {
scheduleRender(!isLoading.value);
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
});
function scheduleRender(force = false) {
if (force) {
renderNow();
return;
}
const elapsed = Date.now() - lastRenderAt;
if (elapsed >= RENDER_THROTTLE_MS) {
renderNow();
return;
}
if (!pendingTimer) {
pendingTimer = setTimeout(renderNow, RENDER_THROTTLE_MS - elapsed);
}
}
function renderNow() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
lastRenderAt = Date.now();
renderedSrcdoc.value = buildSrcdoc(htmlContent.value, isDark.value);
}
function stripHtmlGenUiWrapper(value: string) {
return value
.replace(/^\s*<html-genui\b[^>]*>/i, "")
.replace(/<\/html-genui>\s*$/i, "")
.trim();
}
function attrValue(name: string) {
const attr = props.node?.attrs?.find(
([key]) => key.toLowerCase() === name.toLowerCase(),
);
return attr?.[1]?.trim() || "";
}
function buildSrcdoc(content: string, dark: boolean) {
const headExtras = buildHeadExtras(dark);
if (/<html[\s>]/i.test(content)) {
return injectHeadExtras(content, headExtras);
}
return `<!doctype html>
<html>
<head>${headExtras}</head>
<body>${content}</body>
</html>`;
}
function injectHeadExtras(html: string, headExtras: string) {
if (/<head[\s>]/i.test(html)) {
return html.replace(/<head([^>]*)>/i, `<head$1>${headExtras}`);
}
if (/<html[\s>]/i.test(html)) {
return html.replace(/<html([^>]*)>/i, `<html$1><head>${headExtras}</head>`);
}
return `<!doctype html><html><head>${headExtras}</head><body>${html}</body></html>`;
}
function buildHeadExtras(dark: boolean) {
const bg = dark ? "#111827" : "#ffffff";
const fg = dark ? "#f9fafb" : "#111827";
return `<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<base target="_blank">
<style>
:root { color-scheme: ${dark ? "dark" : "light"}; }
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
background: ${bg};
color: ${fg};
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
img, video, canvas, svg { max-width: 100%; }
</style>`;
}
</script>
<style scoped>
.html-genui-node {
width: 100%;
margin: 12px 0;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 8px;
background: rgb(var(--v-theme-surface));
}
.html-genui-node.is-dark {
border-color: rgba(160, 160, 160, 0.28);
}
.html-genui-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 8px 10px 8px 12px;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
background: rgba(128, 128, 128, 0.04);
}
.html-genui-title {
min-width: 0;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.84);
font-size: 13px;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.html-genui-toggle {
display: inline-flex;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 6px;
background: rgba(128, 128, 128, 0.06);
}
.html-genui-toggle-button {
min-width: 64px;
border: 0;
border-right: 1px solid rgba(128, 128, 128, 0.2);
padding: 4px 10px;
background: transparent;
color: rgba(var(--v-theme-on-surface), 0.68);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.html-genui-toggle-button:focus {
outline: none;
}
.html-genui-toggle-button:focus-visible {
outline: 2px solid rgba(128, 128, 128, 0.36);
outline-offset: -2px;
}
.html-genui-toggle-button:last-child {
border-right: 0;
}
.html-genui-toggle-button.active {
background: rgba(128, 128, 128, 0.16);
color: rgba(var(--v-theme-on-surface), 0.92);
}
.html-genui-frame {
display: block;
width: 100%;
height: clamp(280px, 52vh, 620px);
border: 0;
background: #fff;
}
.html-genui-node.is-loading .html-genui-frame {
opacity: 0.96;
}
.html-genui-source {
height: clamp(280px, 52vh, 620px);
margin: 0;
overflow: auto;
padding: 14px;
background: rgba(var(--v-theme-on-surface), 0.035);
color: rgba(var(--v-theme-on-surface), 0.86);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.55;
tab-size: 2;
white-space: pre-wrap;
word-break: break-word;
}
</style>
+12 -2
View File
@@ -4,6 +4,14 @@ import { fetchWithAuth } from "@/api/http";
export type TransportMode = "sse" | "websocket";
export function buildChatRequestFlags(enableStreaming = true) {
return {
enable_inline_genui: true,
enable_default_system_prompt: true,
enable_streaming: enableStreaming,
};
}
export interface MessagePart {
type: string;
text?: string;
@@ -480,6 +488,7 @@ export function useMessages(options: UseMessagesOptions) {
botRecord: ChatRecord,
selectedProvider = "",
selectedModel = "",
enableStreaming = true,
) {
if (!sessionId || botRecord.id == null) return;
const targetMessageId = botRecord.id;
@@ -515,6 +524,7 @@ export function useMessages(options: UseMessagesOptions) {
body: JSON.stringify({
selected_provider: selectedProvider,
selected_model: selectedModel,
flags: buildChatRequestFlags(enableStreaming),
}),
signal: abort.signal,
},
@@ -635,7 +645,7 @@ export function useMessages(options: UseMessagesOptions) {
body: JSON.stringify({
session_id: sessionId,
message: parts.map(partToPayload),
enable_streaming: enableStreaming,
flags: buildChatRequestFlags(enableStreaming),
selected_provider: selectedProvider,
selected_model: selectedModel,
_skip_user_history: skipUserHistory,
@@ -785,7 +795,7 @@ export function useMessages(options: UseMessagesOptions) {
session_id: sessionId,
message_id: messageId,
message: parts.map(partToPayload),
enable_streaming: enableStreaming,
flags: buildChatRequestFlags(enableStreaming),
selected_provider: selectedProvider,
selected_model: selectedModel,
});
+117 -3
View File
@@ -3418,6 +3418,31 @@
}
}
},
"/api/v1/plugins/validate/repo": {
"post": {
"tags": [
"Plugins"
],
"summary": "Validate a GitHub plugin repository before installation",
"operationId": "validatePluginRepo",
"x-astrbot-scope": "plugin",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginValidateRepoRequest"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
}
},
"/api/v1/plugins/failed": {
"get": {
"tags": [
@@ -5992,6 +6017,28 @@
},
"additionalProperties": false
},
"ChatFlags": {
"type": "object",
"description": "Per-request ChatUI feature flags. A value here takes priority over its legacy top-level field, followed by the documented default.",
"properties": {
"enable_inline_genui": {
"type": "boolean",
"default": true,
"description": "Inject the inline HTML GenUI system prompt for this request."
},
"enable_default_system_prompt": {
"type": "boolean",
"default": true,
"description": "Allow the ChatUI default system prompt when no persona overrides it."
},
"enable_streaming": {
"type": "boolean",
"default": true,
"description": "Enable streaming model output for this request. This value takes priority over the legacy top-level enable_streaming field."
}
},
"additionalProperties": false
},
"ChatRequest": {
"type": "object",
"required": [
@@ -6036,7 +6083,12 @@
},
"enable_streaming": {
"type": "boolean",
"default": true
"default": true,
"deprecated": true,
"description": "Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority."
},
"flags": {
"$ref": "#/components/schemas/ChatFlags"
},
"_skip_user_history": {
"type": "boolean",
@@ -6105,7 +6157,12 @@
},
"enable_streaming": {
"type": "boolean",
"default": true
"default": true,
"deprecated": true,
"description": "Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority."
},
"flags": {
"$ref": "#/components/schemas/ChatFlags"
}
},
"additionalProperties": false
@@ -6164,7 +6221,12 @@
},
"enable_streaming": {
"type": "boolean",
"default": true
"default": true,
"deprecated": true,
"description": "Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority."
},
"flags": {
"$ref": "#/components/schemas/ChatFlags"
}
},
"additionalProperties": false
@@ -6180,6 +6242,17 @@
},
"description": {
"type": "string"
},
"workspace_type": {
"type": "string",
"enum": [
"session",
"project",
"custom"
]
},
"workspace_path": {
"type": "string"
}
},
"additionalProperties": false
@@ -6215,6 +6288,9 @@
"filename": {
"type": "string"
},
"stored_filename": {
"type": "string"
},
"mime_type": {
"type": "string"
}
@@ -6272,6 +6348,9 @@
"PluginSourceBindRequest": {
"type": "object",
"properties": {
"install_method": {
"type": "string"
},
"registry_url": {
"type": "string",
"nullable": true
@@ -6337,6 +6416,16 @@
},
"ignore_version_check": {
"type": "boolean"
},
"install_method": {
"type": "string"
},
"registry_url": {
"type": "string",
"nullable": true
},
"market_plugin_id": {
"type": "string"
}
},
"additionalProperties": false
@@ -6361,6 +6450,31 @@
},
"ignore_version_check": {
"type": "boolean"
},
"install_method": {
"type": "string"
},
"registry_url": {
"type": "string",
"nullable": true
},
"market_plugin_id": {
"type": "string"
}
},
"additionalProperties": false
},
"PluginValidateRepoRequest": {
"type": "object",
"properties": {
"repository": {
"type": "string"
},
"url": {
"type": "string"
},
"proxy": {
"type": "string"
}
},
"additionalProperties": false
+30
View File
@@ -5326,6 +5326,24 @@ components:
$ref: "#/components/schemas/DynamicConfig"
additionalProperties: false
ChatFlags:
type: object
description: Per-request ChatUI feature flags. A value here takes priority over its legacy top-level field, followed by the documented default.
properties:
enable_inline_genui:
type: boolean
default: true
description: Inject the inline HTML GenUI system prompt for this request.
enable_default_system_prompt:
type: boolean
default: true
description: Allow the ChatUI default system prompt when no persona overrides it.
enable_streaming:
type: boolean
default: true
description: Enable streaming model output for this request. This value takes priority over the legacy top-level enable_streaming field.
additionalProperties: false
ChatRequest:
type: object
required: [message]
@@ -5355,6 +5373,10 @@ components:
enable_streaming:
type: boolean
default: true
deprecated: true
description: Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
flags:
$ref: "#/components/schemas/ChatFlags"
_skip_user_history:
type: boolean
description: Internal WebUI flag for edit/regenerate flows.
@@ -5405,6 +5427,10 @@ components:
enable_streaming:
type: boolean
default: true
deprecated: true
description: Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
flags:
$ref: "#/components/schemas/ChatFlags"
additionalProperties: false
ChatThreadCreateRequest:
@@ -5438,6 +5464,10 @@ components:
enable_streaming:
type: boolean
default: true
deprecated: true
description: Deprecated compatibility field. It is used only when flags.enable_streaming is absent; otherwise flags.enable_streaming takes priority.
flags:
$ref: "#/components/schemas/ChatFlags"
additionalProperties: false
ChatProjectRequest:
+36
View File
@@ -387,6 +387,42 @@ async def test_legacy_chat_stream_keeps_existing_event_shape(chat_service_instan
chat_service.webchat_queue_mgr.remove_queues(session_id)
@pytest.mark.asyncio
async def test_chat_stream_forwards_normalized_request_flags(chat_service_instance):
"""Test chat requests pass normalized flags to the WebChat adapter queue."""
service = chat_service_instance
session_id = "request-flags-session"
stream = await service.build_chat_stream(
"alice",
{
"message": "hello",
"session_id": session_id,
"enable_streaming": False,
"flags": {
"enable_inline_genui": True,
"enable_default_system_prompt": False,
},
},
)
run = next(iter(service.chat_runs.values()))
try:
chat_queue = chat_service.webchat_queue_mgr.get_or_create_queue(session_id)
_, _, payload = await asyncio.wait_for(chat_queue.get(), timeout=1)
assert payload["flags"] == {
"enable_inline_genui": True,
"enable_default_system_prompt": False,
"enable_streaming": False,
}
assert "enable_streaming" not in payload
finally:
await stream.aclose()
if run.task and not run.task.done():
run.task.cancel()
await asyncio.gather(run.task, return_exceptions=True)
chat_service.webchat_queue_mgr.remove_queues(session_id)
@pytest.mark.asyncio
async def test_chat_stream_forwards_follow_up_status_by_default(
chat_service_instance,
+57
View File
@@ -791,6 +791,63 @@ class TestEnsurePersonaAndSkills:
assert "Custom persona." in req.system_prompt
@pytest.mark.asyncio
async def test_inline_genui_prompt_is_added_with_custom_persona(
self, mock_event, mock_context
):
"""Test inline GenUI instructions are independent of persona selection."""
module = ama
persona = {"name": "conv-persona", "prompt": "Custom persona."}
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("conv-persona", persona, None, False)
)
mock_event.get_extra.side_effect = (
lambda key: key == "enable_inline_genui"
)
req = ProviderRequest()
req.conversation = MagicMock(persona_id="conv-persona")
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert "Custom persona." in req.system_prompt
assert module.CHATUI_INLINE_GENUI_SYSTEM_PROMPT in req.system_prompt
@pytest.mark.asyncio
async def test_inline_genui_prompt_does_not_require_conversation(
self, mock_event, mock_context
):
"""Test inline GenUI instructions are added before conversation setup."""
module = ama
mock_event.get_extra.side_effect = (
lambda key: key == "enable_inline_genui"
)
req = ProviderRequest()
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert module.CHATUI_INLINE_GENUI_SYSTEM_PROMPT in req.system_prompt
mock_context.persona_manager.resolve_selected_persona.assert_not_awaited()
@pytest.mark.asyncio
async def test_default_system_prompt_can_be_disabled(
self, mock_event, mock_context
):
"""Test the default ChatUI persona prompt honors its request flag."""
module = ama
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("_chatui_default_", None, None, True)
)
mock_event.get_extra.side_effect = lambda key: {
"enable_inline_genui": False,
"enable_default_system_prompt": False,
}.get(key)
req = ProviderRequest()
req.conversation = MagicMock(persona_id=None)
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert module.CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT not in req.system_prompt
@pytest.mark.asyncio
async def test_ensure_persona_none_explicit(self, mock_event, mock_context):
"""Test that [%None] persona is explicitly set to no persona."""
+38
View File
@@ -0,0 +1,38 @@
from astrbot.core.platform.sources.webchat.request_flags import (
resolve_webchat_request_flags,
)
def test_webchat_request_flags_use_defaults():
flags = resolve_webchat_request_flags({})
assert flags == {
"enable_inline_genui": True,
"enable_default_system_prompt": True,
"enable_streaming": True,
}
def test_webchat_request_flags_prefer_nested_flags():
flags = resolve_webchat_request_flags(
{
"enable_streaming": False,
"flags": {
"enable_inline_genui": False,
"enable_default_system_prompt": False,
"enable_streaming": True,
},
}
)
assert flags == {
"enable_inline_genui": False,
"enable_default_system_prompt": False,
"enable_streaming": True,
}
def test_webchat_request_flags_keep_legacy_streaming_fallback():
flags = resolve_webchat_request_flags({"enable_streaming": False})
assert flags["enable_streaming"] is False