mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-30 17:33:24 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1464,6 +1464,7 @@ async function handleRegenerateMessage(
|
||||
message,
|
||||
selection?.providerId || "",
|
||||
selection?.modelName || "",
|
||||
enableStreaming.value,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user