Compare commits

...

11 Commits

33 changed files with 1829 additions and 442 deletions

View File

@ -1,6 +1,6 @@
{ {
"model_profile": "balanced", "model_profile": "balanced",
"commit_docs": true, "commit_docs": false,
"parallelization": true, "parallelization": true,
"search_gitignored": false, "search_gitignored": false,
"brave_search": false, "brave_search": false,

View File

@ -10,12 +10,14 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
import time import time
from typing import Any from typing import Any
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from openai import AsyncOpenAI
from app.gateway.deps import get_checkpointer, get_run_manager, get_store, get_stream_bridge from app.gateway.deps import get_checkpointer, get_run_manager, get_store, get_stream_bridge
from deerflow.runtime import ( from deerflow.runtime import (
@ -32,6 +34,17 @@ from deerflow.runtime import (
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 预处理提示词的大模型
PPT_INSUFFICIENT_INFO_FORWARD = "用户想生成ppt但是没有输入足够多的信息所以先向用户询问更多信息"
PPT_SELECTOR_SYSTEM_PROMPT = """#PPT
你是 PPT 技能选择器严格执行以下流程
用户输入生成 PPT 相关指令后询问你需要使用哪个生成 PPT 的技能可选技能1. ppt_gen_html生成 HTML 形式 PPT2. ppt_gen_reference根据文档生成 PPT
记住用户最初的 PPT 指令
用户选择技能后仅输出固定语句无任何多余内容
ppt_gen_html{user_input}使用 ppt_gen_html 这个 skill 来完成
ppt_gen_reference{user_input}使用 ppt_gen_reference 这个 skill 来完成
{user_input} 特指用户最初输入的 PPT 制作指令非选择回复"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -94,6 +107,137 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
return raw_input return raw_input
def _extract_text_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
elif isinstance(item, str) and item.strip():
parts.append(item.strip())
return "\n".join(parts)
return str(content or "")
def _extract_last_human_text(graph_input: dict[str, Any]) -> str:
messages = graph_input.get("messages")
if not isinstance(messages, list):
return ""
for msg in reversed(messages):
if isinstance(msg, HumanMessage):
return _extract_text_content(msg.content).strip()
if isinstance(msg, dict):
role = str(msg.get("role", msg.get("type", ""))).lower()
if role in {"user", "human"}:
return _extract_text_content(msg.get("content")).strip()
return ""
def _is_ppt_request(text: str) -> bool:
lowered = text.lower()
return any(token in lowered for token in ("ppt", "slides", "powerpoint", "幻灯片", "演示文稿"))
def _heuristic_has_enough_ppt_info(text: str) -> bool:
lowered = text.lower()
if len(lowered.strip()) < 12:
return False
score = 0
if len(lowered) >= 24:
score += 1
if re.search(r"(关于|主题|topic|题目|on\s+)", lowered):
score += 1
if re.search(r"(面向|给|用于|目的|audience|for\s+)", lowered):
score += 1
if re.search(r"(\d+\s*(页|p|slides?)|大纲|目录|章节|结构)", lowered):
score += 1
if re.search(r"(风格|配色|模板|视觉|语气|style|tone)", lowered):
score += 1
if re.search(r"(根据|参考|数据|附件|文档|material|reference)", lowered):
score += 1
return score >= 2
async def _deepseek_ppt_info_check(user_text: str) -> bool:
enabled = os.getenv("PPT_PRECHECK_ENABLED", "true").strip().lower()
if enabled in {"0", "false", "off", "no"}:
return True
base_url = os.getenv("PPT_PRECHECK_BASE_URL", "").strip()
api_key = os.getenv("PPT_PRECHECK_API_KEY", "").strip()
model = os.getenv("PPT_PRECHECK_MODEL", "deepseek-chat").strip()
timeout_s = float(os.getenv("PPT_PRECHECK_TIMEOUT_SECONDS", "10").strip() or "10")
if not base_url or not api_key:
return _heuristic_has_enough_ppt_info(user_text)
check_instruction = (
"你现在只做“PPT信息是否足够”的判断不做技能追问。"
"判断标准:至少包含主题 + 另一个关键信息(受众/用途/页数或结构/风格/参考资料)。"
"仅输出一个词ENOUGH 或 INSUFFICIENT。"
)
system_prompt = f"{PPT_SELECTOR_SYSTEM_PROMPT}\n\n{check_instruction}"
try:
client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s)
resp = await client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
)
content = (resp.choices[0].message.content or "").strip().upper()
if "INSUFFICIENT" in content:
return False
if "ENOUGH" in content:
return True
logger.warning("PPT precheck unexpected output: %r; fallback to heuristic", content)
except Exception:
logger.warning("PPT precheck via DeepSeek failed; fallback to heuristic", exc_info=True)
return _heuristic_has_enough_ppt_info(user_text)
def _overwrite_last_human_message(graph_input: dict[str, Any], text: str) -> None:
messages = graph_input.get("messages")
if not isinstance(messages, list):
graph_input["messages"] = [HumanMessage(content=text)]
return
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if isinstance(msg, HumanMessage):
msg.content = text
return
if isinstance(msg, dict):
role = str(msg.get("role", msg.get("type", ""))).lower()
if role in {"user", "human"}:
msg["content"] = text
return
messages.append(HumanMessage(content=text))
async def _maybe_apply_ppt_precheck(graph_input: dict[str, Any]) -> None:
user_text = _extract_last_human_text(graph_input)
if not user_text or not _is_ppt_request(user_text):
return
enough = await _deepseek_ppt_info_check(user_text)
if enough:
return
_overwrite_last_human_message(graph_input, PPT_INSUFFICIENT_INFO_FORWARD)
logger.info("PPT precheck flagged insufficient info; forwarded clarification instruction")
_DEFAULT_ASSISTANT_ID = "lead_agent" _DEFAULT_ASSISTANT_ID = "lead_agent"
@ -282,6 +426,7 @@ async def start_run(
agent_factory = resolve_agent_factory(body.assistant_id) agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input) graph_input = normalize_input(body.input)
await _maybe_apply_ppt_precheck(graph_input)
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id) config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
if "configurable" in config and isinstance(config["configurable"], dict): if "configurable" in config and isinstance(config["configurable"], dict):

View File

@ -3,6 +3,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
from unittest.mock import AsyncMock, patch
from langchain_core.messages import HumanMessage
def test_format_sse_basic(): def test_format_sse_basic():
@ -81,6 +84,55 @@ def test_normalize_input_passthrough():
assert result == {"custom_key": "value"} assert result == {"custom_key": "value"}
def test_extract_last_human_text_from_human_message():
from app.gateway.services import _extract_last_human_text
graph_input = {
"messages": [
HumanMessage(content="第一条"),
HumanMessage(content=[{"type": "text", "text": "我要做一个产品发布会PPT"}]),
]
}
assert _extract_last_human_text(graph_input) == "我要做一个产品发布会PPT"
def test_is_ppt_request():
from app.gateway.services import _is_ppt_request
assert _is_ppt_request("帮我做个PPT")
assert _is_ppt_request("Please generate slides for roadmap")
assert not _is_ppt_request("帮我写一段 SQL")
def test_heuristic_has_enough_ppt_info():
from app.gateway.services import _heuristic_has_enough_ppt_info
assert not _heuristic_has_enough_ppt_info("做个ppt")
assert _heuristic_has_enough_ppt_info("做一个关于Q2复盘的PPT面向管理层10页简洁风格")
def test_overwrite_last_human_message():
from app.gateway.services import _overwrite_last_human_message
graph_input = {"messages": [HumanMessage(content="请生成PPT")]}
_overwrite_last_human_message(graph_input, "用户想生成ppt但是没有输入足够多的信息所以先向用户询问更多信息")
assert graph_input["messages"][-1].content == "用户想生成ppt但是没有输入足够多的信息所以先向用户询问更多信息"
def test_maybe_apply_ppt_precheck_rewrites_when_insufficient():
from app.gateway.services import _maybe_apply_ppt_precheck
graph_input = {"messages": [HumanMessage(content="帮我做个PPT")]}
with patch(
"app.gateway.services._deepseek_ppt_info_check",
new=AsyncMock(return_value=False),
):
import asyncio
asyncio.run(_maybe_apply_ppt_precheck(graph_input))
assert graph_input["messages"][-1].content == "用户想生成ppt但是没有输入足够多的信息所以先向用户询问更多信息"
def test_build_run_config_basic(): def test_build_run_config_basic():
from app.gateway.services import build_run_config from app.gateway.services import build_run_config

View File

@ -58,6 +58,7 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.33", "ai": "^6.0.33",
"antd": "^6.3.6",
"best-effort-json-parser": "^1.2.1", "best-effort-json-parser": "^1.2.1",
"better-auth": "^1.3", "better-auth": "^1.3",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { Ticker } from "@tombcato/smart-ticker";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -22,6 +23,7 @@ import {
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats"; import { useThreadChat } from "@/components/workspace/chats";
// import { DevTodoList } from "@/components/workspace/dev-todo-list"; // import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
@ -39,8 +41,7 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener"; import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { Ticker } from "@tombcato/smart-ticker";
import "@tombcato/smart-ticker/style.css"; import "@tombcato/smart-ticker/style.css";
import motivationSlogans from "./motivation-slogans.json"; import motivationSlogans from "./motivation-slogans.json";
@ -489,9 +490,7 @@ export default function ChatPage() {
/> />
) : ( ) : (
<div className="relative flex size-full justify-center px-[20px]"> <div className="relative flex size-full justify-center px-[20px]">
<div className="z-30"> <div className="z-30"></div>
</div>
{thread.values.artifacts?.length === 0 ? ( {thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState <ConversationEmptyState
icon={<FilesIcon />} icon={<FilesIcon />}
@ -500,8 +499,8 @@ export default function ChatPage() {
/> />
) : ( ) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center"> <div className="flex size-full max-w-(--container-width-sm) flex-col justify-center">
<header className="shrink-0 flex justify-between items-center border-b "> <header className="flex shrink-0 items-center justify-between border-b">
<h2 className="text-[14px] h-[58px] leading-[58px] font-bold text-[#333333]"> <h2 className="h-[58px] text-[14px] leading-[58px] font-bold text-[#333333]">
<span>{t.common.artifacts}</span> <span>{t.common.artifacts}</span>
</h2> </h2>
<Button <Button
@ -655,7 +654,9 @@ export default function ChatPage() {
<DevDialogContent> <DevDialogContent>
<DevDialogHeader> <DevDialogHeader>
<DevDialogTitle> <DevDialogTitle>
{selectedSkillError?.title ?? t.chatPage.selectedSkillLoadFailed} {" "}
{selectedSkillError?.title ??
t.chatPage.selectedSkillLoadFailed}
</DevDialogTitle> </DevDialogTitle>
</DevDialogHeader> </DevDialogHeader>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">

View File

@ -36,7 +36,7 @@ export const Message = ({
"group flex w-full flex-col gap-2", "group flex w-full flex-col gap-2",
from === "user" from === "user"
? cn("is-user ml-auto justify-end", !isFirstInSession && "mt-6") ? cn("is-user ml-auto justify-end", !isFirstInSession && "mt-6")
: "is-assistant bg-white rounded-[10px] p-4", : "is-assistant rounded-[10px] bg-white p-4",
className, className,
)} )}
{...props} {...props}

View File

@ -1064,7 +1064,7 @@ export const PromptInputTools = ({
className, className,
...props ...props
}: PromptInputToolsProps) => ( }: PromptInputToolsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} /> <div className={cn("flex items-center h-full gap-1", className)} {...props} />
); );
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>; export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;

View File

@ -205,7 +205,7 @@ export const ReasoningContent = memo(
{...props} {...props}
> >
{isStreaming ? ( {isStreaming ? (
<div className="whitespace-pre-wrap break-words">{children}</div> <div className="break-words whitespace-pre-wrap">{children}</div>
) : ( ) : (
<Streamdown <Streamdown
isAnimating={false} isAnimating={false}

View File

@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ContextMenu({ function ContextMenu({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} /> return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
} }
function ContextMenuTrigger({ function ContextMenuTrigger({
@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return ( return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} /> <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
) );
} }
function ContextMenuGroup({ function ContextMenuGroup({
@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return ( return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} /> <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
) );
} }
function ContextMenuPortal({ function ContextMenuPortal({
@ -33,13 +33,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return ( return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} /> <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
) );
} }
function ContextMenuSub({ function ContextMenuSub({
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) { }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} /> return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
} }
function ContextMenuRadioGroup({ function ContextMenuRadioGroup({
@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group" data-slot="context-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function ContextMenuSubTrigger({ function ContextMenuSubTrigger({
@ -59,22 +59,22 @@ function ContextMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger" data-slot="context-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
) );
} }
function ContextMenuSubContent({ function ContextMenuSubContent({
@ -85,12 +85,12 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content" data-slot="context-menu-sub-content"
className={cn( className={cn(
"z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuContent({ function ContextMenuContent({
@ -102,13 +102,13 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-0 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-0 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</ContextMenuPrimitive.Portal> </ContextMenuPrimitive.Portal>
) );
} }
function ContextMenuItem({ function ContextMenuItem({
@ -117,8 +117,8 @@ function ContextMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
@ -126,12 +126,12 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuCheckboxItem({ function ContextMenuCheckboxItem({
@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item" data-slot="context-menu-checkbox-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
) );
} }
function ContextMenuRadioItem({ function ContextMenuRadioItem({
@ -169,8 +169,8 @@ function ContextMenuRadioItem({
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item" data-slot="context-menu-radio-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
) );
} }
function ContextMenuLabel({ function ContextMenuLabel({
@ -189,19 +189,19 @@ function ContextMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & { }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
data-slot="context-menu-label" data-slot="context-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8", "text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ContextMenuSeparator({ function ContextMenuSeparator({
@ -211,10 +211,10 @@ function ContextMenuSeparator({
return ( return (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
data-slot="context-menu-separator" data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function ContextMenuShortcut({ function ContextMenuShortcut({
@ -225,12 +225,12 @@ function ContextMenuShortcut({
<span <span
data-slot="context-menu-shortcut" data-slot="context-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -249,4 +249,4 @@ export {
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuRadioGroup,
} };

View File

@ -81,7 +81,9 @@ export function DropdownSelector<T extends string>({
} }
> >
<span className="flex w-full items-center justify-center gap-1"> <span className="flex w-full items-center justify-center gap-1">
{truncateMiddle(selectedOption?.label ?? value, 30)} {/* {truncateMiddle(selectedOption?.label ?? value, 20)} */}
{truncateMiddle("hfiqwertyuiopasdfghjklxcvbnm.html", 20)}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />} {isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span> </span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -98,7 +100,8 @@ export function DropdownSelector<T extends string>({
value={option.value} value={option.value}
title={option.label} title={option.label}
> >
{truncateMiddle(option.label)} {/* {truncateMiddle(option.label,50)} */}
{truncateMiddle("hfiqwertyuiopasdfghjklxcvbnm.html", 20)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>

View File

@ -37,7 +37,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "text-muted-foreground flex h-[58px] cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{ {
variants: { variants: {
align: { align: {
@ -46,9 +46,9 @@ const inputGroupAddonVariants = cva(
"inline-end": "inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start": "block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", "order-first w-full justify-start px-3 pt-5 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end": "block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", "order-last w-full justify-start px-3 py-0 pb-5 group-has-[>input]/input-group:pb-2.5",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -8,8 +8,11 @@ import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
children, children,
hideScrollbar = true,
...props ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { }: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
hideScrollbar?: boolean;
}) {
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
@ -22,8 +25,8 @@ function ScrollArea({
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar hidden={hideScrollbar} />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner hidden={hideScrollbar} />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
); );
} }

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Slider({ function Slider({
className, className,
@ -20,8 +20,8 @@ function Slider({
: Array.isArray(defaultValue) : Array.isArray(defaultValue)
? defaultValue ? defaultValue
: [min, max], : [min, max],
[value, defaultValue, min, max] [value, defaultValue, min, max],
) );
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
@ -32,20 +32,20 @@ function Slider({
max={max} max={max}
className={cn( className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col", "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className className,
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track <SliderPrimitive.Track
data-slot="slider-track" data-slot="slider-track"
className={cn( className={cn(
"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5" "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)} )}
> >
<SliderPrimitive.Range <SliderPrimitive.Range
data-slot="slider-range" data-slot="slider-range"
className={cn( className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full" "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)} )}
/> />
</SliderPrimitive.Track> </SliderPrimitive.Track>
@ -53,11 +53,11 @@ function Slider({
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
data-slot="slider-thumb" data-slot="slider-thumb"
key={index} key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
) );
} }
export { Slider } export { Slider };

View File

@ -1,3 +1,4 @@
import ExcelJS from "exceljs";
import JSZip from "jszip"; import JSZip from "jszip";
import { import {
DownloadIcon, DownloadIcon,
@ -17,7 +18,6 @@ import {
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import ExcelJS from "exceljs";
import { import {
Artifact, Artifact,
@ -73,13 +73,11 @@ let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() { function ensureRevoGridDefined() {
if (typeof window === "undefined") return Promise.resolve(); if (typeof window === "undefined") return Promise.resolve();
if (window.customElements.get("revo-grid")) return Promise.resolve(); if (window.customElements.get("revo-grid")) return Promise.resolve();
if (!revoGridLoaderPromise) { revoGridLoaderPromise ??= import("@revolist/revogrid/loader").then(
revoGridLoaderPromise = import("@revolist/revogrid/loader").then(
({ defineCustomElements }) => { ({ defineCustomElements }) => {
defineCustomElements(window); defineCustomElements(window);
}, },
); );
}
return revoGridLoaderPromise; return revoGridLoaderPromise;
} }
@ -99,18 +97,45 @@ function toGridCellText(cell: ExcelJS.Cell): string {
const value = cell.value; const value = cell.value;
if (value == null) return ""; if (value == null) return "";
if (value instanceof Date) return value.toISOString(); if (value instanceof Date) return value.toISOString();
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (typeof value === "object") { if (typeof value === "object") {
if ("result" in value && value.result != null) { if ("result" in value && value.result != null) {
return String(value.result); const result = value.result;
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean" ||
typeof result === "bigint"
) {
return String(result);
}
} }
if ("text" in value && value.text) { if ("text" in value && value.text) {
return String(value.text); const text = value.text;
if (
typeof text === "string" ||
typeof text === "number" ||
typeof text === "boolean" ||
typeof text === "bigint"
) {
return String(text);
}
} }
if ("hyperlink" in value && value.hyperlink) { if ("hyperlink" in value && value.hyperlink) {
return String(value.hyperlink); const hyperlink = value.hyperlink;
if (typeof hyperlink === "string") {
return hyperlink;
} }
} }
return String(value); }
return "";
} }
function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData { function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData {
@ -398,8 +423,8 @@ export function ArtifactFileDetail({
className, className,
)} )}
> >
<ArtifactHeader className="grid grid-cols-12 gap-3"> <ArtifactHeader className="grid grid-cols-24">
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden"> <div className="col-span-7 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && ( {previewable && (
<ToggleGroup <ToggleGroup
type="single" type="single"
@ -464,11 +489,11 @@ export function ArtifactFileDetail({
<ArtifactZoomSelector value={zoom} onChange={setZoom} /> <ArtifactZoomSelector value={zoom} onChange={setZoom} />
) : null} ) : null}
</div> </div>
<div className="col-span-6 flex min-w-0 items-center justify-center px-1"> <div className="col-span-10 flex min-w-0 items-center justify-center px-1">
<ArtifactTitle> <ArtifactTitle>
{isWriteFile ? ( {isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap"> <div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
{truncateMiddle(getFileName(filepath), 50)} {truncateMiddle(getFileName(filepath), 20)}
</div> </div>
) : ( ) : (
<DropdownSelector <DropdownSelector
@ -479,7 +504,7 @@ export function ArtifactFileDetail({
)} )}
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden"> <div className="col-span-7 flex min-w-0 items-center justify-end overflow-hidden">
<ArtifactActions> <ArtifactActions>
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
@ -1702,12 +1727,18 @@ export const ArtifactZoomSelector = ({
</svg> </svg>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-52 p-[20px] "> <DropdownMenuContent
align="start"
sideOffset={8}
className="w-52 p-[20px]"
>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{ZOOM_LEVELS[0]}% {ZOOM_LEVELS[0]}%
</span> </span>
<span className="text-foreground text-xs font-medium">{value}%</span> <span className="text-foreground text-xs font-medium">
{value}%
</span>
</div> </div>
<Slider <Slider
min={0} min={0}

View File

@ -160,7 +160,9 @@ const ChatBox: React.FC<{
) : ( ) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8"> <div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0"> <header className="shrink-0">
<h2 className="text-lg font-medium">{t.common.artifacts}</h2> <h2 className="text-lg font-medium">
{t.common.artifacts}
</h2>
</header> </header>
<main className="min-h-0 grow"> <main className="min-h-0 grow">
<ArtifactFileList <ArtifactFileList

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { Tour } from "antd";
import { import {
CheckIcon, CheckIcon,
GraduationCapIcon, GraduationCapIcon,
@ -15,8 +14,11 @@ import {
XIcon, XIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { import {
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -25,7 +27,9 @@ import {
type ChangeEvent, type ChangeEvent,
type KeyboardEvent, type KeyboardEvent,
type ComponentProps, type ComponentProps,
type RefObject,
} from "react"; } from "react";
import { toast } from "sonner";
import { import {
PromptInput, PromptInput,
@ -71,15 +75,14 @@ import { useI18n } from "@/core/i18n/hooks";
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types"; import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { import {
MENTION_REFERENCE_EVENT, MENTION_REFERENCE_EVENT,
type MentionReferenceEventDetail, type MentionReferenceEventDetail,
} from "@/core/threads/reference-events"; } from "@/core/threads/reference-events";
import type { AgentThreadContext } from "@/core/threads";
import { useUploadedFiles } from "@/core/uploads/hooks"; import { useUploadedFiles } from "@/core/uploads/hooks";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { import {
ModelSelector, ModelSelector,
@ -91,13 +94,33 @@ import {
ModelSelectorTrigger, ModelSelectorTrigger,
} from "../ai-elements/model-selector"; } from "../ai-elements/model-selector";
import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import { ScrollArea } from "../ui/scroll-area";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
import { useThread } from "./messages/context";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
const MAX_REFERENCES_PER_MESSAGE = 10; const MAX_REFERENCES_PER_MESSAGE = 10;
const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1";
type WorkspaceToolButtonProps = ComponentProps<typeof PromptInputButton>;
function WorkspaceToolButton({
className,
...props
}: WorkspaceToolButtonProps) {
return (
<PromptInputButton
className={cn(
// border border-[rgba(0,0,0,0.08)]
"group h-full p-[10px]! rounded-[10px] hover:bg-[#EAE2F5] hover:text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
type MentionCandidate = { type MentionCandidate = {
key: string; key: string;
@ -214,6 +237,10 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mentionTriggerRef = useRef<HTMLButtonElement | null>(null); const mentionTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyButtonTourRef = useRef<HTMLDivElement | null>(null);
const attachmentsButtonTourRef = useRef<HTMLDivElement | null>(null);
const skillButtonTourRef = useRef<HTMLDivElement | null>(null);
const suggestionListTourRef = useRef<HTMLDivElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false);
@ -230,11 +257,97 @@ export function InputBox({
start: number; start: number;
end: number; end: number;
} | null>(null); } | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps); const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息) // isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused = const effectiveIsFocused =
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused; ((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused;
const shouldShowSuggestionList =
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill";
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted) {
setIsInputToolsTourReady(false);
return;
}
const frameId = window.requestAnimationFrame(() => {
setIsInputToolsTourReady(
Boolean(
historyButtonTourRef.current &&
attachmentsButtonTourRef.current &&
skillButtonTourRef.current &&
(!shouldShowSuggestionList || suggestionListTourRef.current),
),
);
});
return () => window.cancelAnimationFrame(frameId);
}, [
showWelcomeStyle,
hasSubmitted,
shouldShowSuggestionList,
iframeSkill.isBootstrapping,
iframeSkill.selectedSkills.length,
]);
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) {
setIsInputToolsTourOpen(false);
return;
}
const hasSeenTour = window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY);
if (!hasSeenTour) {
setIsInputToolsTourOpen(true);
}
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady]);
const closeInputToolsTour = useCallback(() => {
window.localStorage.setItem(INPUT_TOOLS_TOUR_SEEN_KEY, "1");
setIsInputToolsTourOpen(false);
}, []);
const inputToolsTourSteps = useMemo(() => {
const baseSteps = [
{
title: "查看历史",
description: "点击这里,可以查看历史会话与文档。",
target: () => historyButtonTourRef.current ?? document.body,
},
{
title: "上传附件",
description: "点击这里,上传参考文档或拟处理的文档。",
target: () => attachmentsButtonTourRef.current ?? document.body,
},
{
title: "选择 Skill",
description: (
<>
skill使skill
<br />
广skill使skill
</>
),
target: () => skillButtonTourRef.current ?? document.body,
},
...(shouldShowSuggestionList
? [
{
title: "试试我吧",
target: () => suggestionListTourRef.current ?? document.body,
},
]
: []),
];
return baseSteps.map((step, index) => ({
...step,
prevButtonProps: { children: "上一步" },
nextButtonProps: {
children: index === baseSteps.length - 1 ? "完成" : "下一步",
},
}));
}, [shouldShowSuggestionList]);
// 点击外部区域时收起输入框 // 点击外部区域时收起输入框
useEffect(() => { useEffect(() => {
@ -372,7 +485,14 @@ export function InputBox({
}); });
setReferences([]); setReferences([]);
}, },
[showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills], [
showWelcomeStyle,
onSubmit,
onStop,
references,
status,
iframeSkill.selectedSkills,
],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {
@ -380,7 +500,8 @@ export function InputBox({
form?.requestSubmit(); form?.requestSubmit();
}, []); }, []);
const addMentionReference = useCallback((reference: PromptInputReference) => { const addMentionReference = useCallback(
(reference: PromptInputReference) => {
setReferences((prev) => { setReferences((prev) => {
const exists = prev.some( const exists = prev.some(
(item) => (item) =>
@ -397,7 +518,9 @@ export function InputBox({
} }
return prev.concat(reference); return prev.concat(reference);
}); });
}, [t.inputBox.maxReferencesReached]); },
[t.inputBox.maxReferencesReached],
);
const selectMentionCandidate = useCallback( const selectMentionCandidate = useCallback(
(candidate: MentionCandidate) => { (candidate: MentionCandidate) => {
@ -432,7 +555,7 @@ export function InputBox({
useEffect(() => { useEffect(() => {
const onMentionReference = (event: Event) => { const onMentionReference = (event: Event) => {
const detail = (event as CustomEvent<MentionReferenceEventDetail>).detail; const detail = (event as CustomEvent<MentionReferenceEventDetail>).detail;
if (!detail || detail.threadId !== threadIdFromProps) { if (detail?.threadId !== threadIdFromProps) {
return; return;
} }
addMentionReference({ addMentionReference({
@ -492,12 +615,13 @@ export function InputBox({
} }
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
setActiveMentionIndex((prev) => setActiveMentionIndex(
(prev + 1) % filteredMentionCandidates.length, (prev) => (prev + 1) % filteredMentionCandidates.length,
); );
} else if (event.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
setActiveMentionIndex((prev) => setActiveMentionIndex(
(prev) =>
(prev - 1 + filteredMentionCandidates.length) % (prev - 1 + filteredMentionCandidates.length) %
filteredMentionCandidates.length, filteredMentionCandidates.length,
); );
@ -603,6 +727,21 @@ export function InputBox({
}} }}
className="relative w-full" className="relative w-full"
> >
<Tour
open={isInputToolsTourOpen}
onClose={closeInputToolsTour}
onFinish={closeInputToolsTour}
gap={
{ offset: 4 , radius: 2 }
}
mask={{
style: {
boxShadow: 'inset 0 0 15px #333',
},
color: 'rgba(255,255,255, .8)',
}}
steps={inputToolsTourSteps}
/>
<AttachmentPreviewBar <AttachmentPreviewBar
references={references} references={references}
threadId={threadId} threadId={threadId}
@ -658,7 +797,11 @@ export function InputBox({
!effectiveIsFocused && "h-[80px] py-0 leading-20", !effectiveIsFocused && "h-[80px] py-0 leading-20",
)} )}
disabled={isInputDisabled} disabled={isInputDisabled}
placeholder={t.inputBox.placeholder} placeholder={
showWelcomeStyle
? t.inputBox.welcomePlaceholder
: t.inputBox.chatPlaceholder
}
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={initialValue} defaultValue={initialValue}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
@ -688,7 +831,7 @@ export function InputBox({
align="start" align="start"
side="top" side="top"
sideOffset={8} sideOffset={8}
className="w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] max-h-[400px] overflow-y-visible p-[20px]" className="max-h-[400px] w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] overflow-y-hidden p-[20px]"
data-testid="mention-candidate-panel" data-testid="mention-candidate-panel"
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
event.preventDefault(); event.preventDefault();
@ -699,8 +842,9 @@ export function InputBox({
{t.inputBox.addReference} {t.inputBox.addReference}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" /> <DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
<DropdownMenuGroup className="flex pt-[20px] px-0 max-h-[480px] flex-col gap-[10px] overflow-y-auto"> <DropdownMenuGroup className="flex max-h-[480px] flex-col gap-[10px] px-0 pt-[20px]">
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => { <ScrollArea className="h-[480px]" data-state="hidden">
{filteredMentionCandidates.map((candidate, index) => {
const detail = [candidate.typeLabel, candidate.pathTail] const detail = [candidate.typeLabel, candidate.pathTail]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
@ -711,7 +855,9 @@ export function InputBox({
"flex items-center justify-between gap-3 rounded-md px-2 py-2 text-left", "flex items-center justify-between gap-3 rounded-md px-2 py-2 text-left",
index === activeMentionIndex && "bg-accent", index === activeMentionIndex && "bg-accent",
)} )}
data-active={index === activeMentionIndex ? "true" : "false"} data-active={
index === activeMentionIndex ? "true" : "false"
}
data-candidate-key={candidate.key} data-candidate-key={candidate.key}
data-testid="mention-candidate-item" data-testid="mention-candidate-item"
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`} aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
@ -744,6 +890,7 @@ export function InputBox({
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
</ScrollArea>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -764,7 +911,7 @@ export function InputBox({
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0", "pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
)} )}
> >
<PromptInputTools className="min-w-0 flex-1 gap-[20px]"> <PromptInputTools className="min-w-0 w-full overflow-hidden gap-[20px]">
{/* TODO: Add more connectors here {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" /> <PromptInputActionMenuTrigger className="px-2!" />
@ -774,19 +921,27 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
{showWelcomeStyle && <HistoryButton {showWelcomeStyle && (
className="px-2!" <div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton
router={router} router={router}
threadId={threadIdFromProps} threadId={threadIdFromProps}
/>} />
<AddAttachmentsButton className="px-2!" /> </div>
)}
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton />
</div>
<div className="min-w-0 grow basis-0 h-full">
<IframeSkillDialogButton <IframeSkillDialogButton
className="px-2!" skillButtonRef={skillButtonTourRef}
selectedSkills={iframeSkill.selectedSkills} selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping} isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog} openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill} clearSkill={iframeSkill.clearSkill}
/> />
</div>
{/* <div className="h-[40px] w-[140px] shrink-0" aria-hidden="true" /> */}
{/* 参考 kexue 版本隐藏运行模式切换按钮 */} {/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools> </PromptInputTools>
@ -823,7 +978,7 @@ export function InputBox({
</ModelSelector> */} </ModelSelector> */}
<PromptInputTools> <PromptInputTools>
{/* 占位符 */} {/* 占位符 */}
<div className="w-[150px]"></div> <div className="w-[150px] h-[40px]"></div>
</PromptInputTools> </PromptInputTools>
</PromptInputFooter> </PromptInputFooter>
<PromptInputSubmit <PromptInputSubmit
@ -834,10 +989,9 @@ export function InputBox({
/> />
</PromptInput> </PromptInput>
{showWelcomeStyle && {shouldShowSuggestionList && (
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
ref={suggestionListTourRef}
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills} bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping} isBootstrapping={iframeSkill.isBootstrapping}
/> />
@ -904,25 +1058,29 @@ export function InputBox({
} }
// SuggestionList 容器 // SuggestionList 容器
function SuggestionListContainer({ const SuggestionListContainer = forwardRef<HTMLDivElement, {
bootstrapAndLockSkills,
isBootstrapping,
}: {
bootstrapAndLockSkills: (params: { bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[]; selectedSkills: SelectedSkillPayloadItem[];
title: string; title: string;
}) => Promise<boolean>; }) => Promise<boolean>;
isBootstrapping: boolean; isBootstrapping: boolean;
}) { }>(
function SuggestionListContainer(
{ bootstrapAndLockSkills, isBootstrapping },
ref,
) {
return ( return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4"> <div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<div ref={ref} className="w-fit">
<SuggestionList <SuggestionList
bootstrapAndLockSkills={bootstrapAndLockSkills} bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping} isBootstrapping={isBootstrapping}
/> />
</div> </div>
</div>
); );
} },
);
// 快速选择skillbutton // 快速选择skillbutton
function SuggestionList({ function SuggestionList({
@ -1024,8 +1182,8 @@ function AddAttachmentsButton({ className }: { className?: string }) {
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
return ( return (
<Tooltip content={t.inputBox.addAttachments}> <Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton <WorkspaceToolButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)} className={className}
onClick={() => attachments.openFileDialog()} onClick={() => attachments.openFileDialog()}
> >
<svg <svg
@ -1045,7 +1203,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
stroke="#150033" stroke="#150033"
/> />
</svg> </svg>
</PromptInputButton> </WorkspaceToolButton>
</Tooltip> </Tooltip>
); );
} }
@ -1062,8 +1220,8 @@ function HistoryButton({
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Tooltip content={t.inputBox.history}> <Tooltip content={t.inputBox.history}>
<PromptInputButton <WorkspaceToolButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)} className={className}
onClick={() => onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`) router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
} }
@ -1089,20 +1247,21 @@ function HistoryButton({
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</WorkspaceToolButton>
</PromptInputButton>
</Tooltip> </Tooltip>
); );
} }
// 启动iframeSkillDialog // 启动iframeSkillDialog
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
skillButtonRef,
selectedSkills, selectedSkills,
isBootstrapping, isBootstrapping,
openSkillDialog, openSkillDialog,
clearSkill, clearSkill,
}: { }: {
className?: string; className?: string;
skillButtonRef?: RefObject<HTMLDivElement | null>;
selectedSkills: Array<{ skill_id: string; title: string }>; selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean; isBootstrapping: boolean;
openSkillDialog: () => void; openSkillDialog: () => void;
@ -1111,10 +1270,11 @@ function IframeSkillDialogButton({
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 w-full items-center h-full gap-2">
<Tooltip content={t.inputBox.selectSkill}> <Tooltip content={t.inputBox.selectSkill}>
<PromptInputButton <div ref={skillButtonRef} className="shrink-0">
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)} <WorkspaceToolButton
className={cn("shrink-0", className)}
onClick={openSkillDialog} onClick={openSkillDialog}
> >
<svg <svg
@ -1128,7 +1288,8 @@ function IframeSkillDialogButton({
stroke="#150033" stroke="#150033"
/> />
</svg> </svg>
</PromptInputButton> </WorkspaceToolButton>
</div>
</Tooltip> </Tooltip>
{isBootstrapping ? ( {isBootstrapping ? (
<Tag className="bg-background text-muted-foreground gap-2 border"> <Tag className="bg-background text-muted-foreground gap-2 border">
@ -1138,7 +1299,7 @@ function IframeSkillDialogButton({
) : null} ) : null}
{!isBootstrapping && selectedSkills.length > 0 ? ( {!isBootstrapping && selectedSkills.length > 0 ? (
<div <div
className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="flex min-w-0 grow basis-0 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onWheel={(event) => { onWheel={(event) => {
if (event.deltaY === 0) return; if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY; event.currentTarget.scrollLeft += event.deltaY;
@ -1198,7 +1359,10 @@ function AttachmentPreviewBar({
</PromptInputAttachments> </PromptInputAttachments>
)} )}
{hasReferences && ( {hasReferences && (
<div className="inline-flex flex-row flex-wrap items-center gap-2 rounded-xl p-2" data-testid="reference-inline-preview"> <div
className="inline-flex flex-row flex-wrap items-center gap-2 rounded-xl p-2"
data-testid="reference-inline-preview"
>
{references.map((reference) => { {references.map((reference) => {
const referenceUrl = const referenceUrl =
threadId && reference.path threadId && reference.path
@ -1208,7 +1372,7 @@ function AttachmentPreviewBar({
}) })
: null; : null;
const filename = reference.filename ?? "reference"; const filename = reference.filename ?? "reference";
const imageMatch = filename.match(/\.(png|jpe?g|gif|webp|bmp|svg)$/i); const imageMatch = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.exec(filename);
const extension = imageMatch?.[1]?.toLowerCase(); const extension = imageMatch?.[1]?.toLowerCase();
const mediaType = extension const mediaType = extension
? extension === "jpg" ? extension === "jpg"

View File

@ -1,14 +1,20 @@
"use client"; "use client";
import { useMemo } from "react"; import { CheckIcon, CopyIcon } from "lucide-react";
import type { AnchorHTMLAttributes } from "react"; import { useCallback, useMemo, useState, type MouseEvent } from "react";
import type {
AnchorHTMLAttributes,
ComponentPropsWithoutRef,
ReactNode,
} from "react";
import { import {
MessageResponse, MessageResponse,
type MessageResponseProps, type MessageResponseProps,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils"; import { cn, copyToClipboard } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
@ -25,6 +31,97 @@ export type MarkdownContentProps = {
components?: MessageResponseProps["components"]; components?: MessageResponseProps["components"];
}; };
type TableData = {
headers: string[];
rows: string[][];
};
function parseTableData(table: HTMLTableElement): TableData {
const headers = Array.from(table.querySelectorAll("thead th")).map((cell) =>
(cell.textContent ?? "").trim(),
);
const rows = Array.from(table.querySelectorAll("tbody tr")).map((row) =>
Array.from(row.querySelectorAll("td")).map((cell) =>
(cell.textContent ?? "").trim(),
),
);
return { headers, rows };
}
function toMarkdownTable(data: TableData): string {
if (data.headers.length === 0) return "";
const headerLine = `| ${data.headers.join(" | ")} |`;
const dividerLine = `| ${data.headers.map(() => "---").join(" | ")} |`;
const rowLines = data.rows.map((row) => `| ${row.join(" | ")} |`);
return [headerLine, dividerLine, ...rowLines].join("\n");
}
function MarkdownTable({
className,
children,
isLoading,
copyLabel,
...props
}: ComponentPropsWithoutRef<"table"> & {
isLoading: boolean;
copyLabel: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
const wrapper = event.currentTarget.closest(
'[data-streamdown="table-wrapper"]',
);
const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return;
const markdown = toMarkdownTable(parseTableData(table));
if (!markdown) return;
try {
await copyToClipboard(markdown);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// no-op
}
},
[],
);
return (
<div
className="my-4 flex flex-col space-y-2"
data-streamdown="table-wrapper"
>
<div className="flex items-center justify-end gap-1">
<button
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
onClick={handleCopy}
title={copyLabel}
type="button"
>
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
</button>
</div>
<div className="overflow-x-auto">
<table
className={cn(
"border-border w-full border-collapse border",
className,
)}
data-streamdown="table"
{...props}
>
{children}
</table>
</div>
</div>
);
}
/** Renders markdown content. */ /** Renders markdown content. */
export function MarkdownContent({ export function MarkdownContent({
content, content,
@ -34,6 +131,8 @@ export function MarkdownContent({
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps, components: componentsFromProps,
}: MarkdownContentProps) { }: MarkdownContentProps) {
const { t } = useI18n();
const components = useMemo(() => { const components = useMemo(() => {
return { return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
@ -58,9 +157,23 @@ export function MarkdownContent({
/> />
); );
}, },
table: ({
children,
className,
...props
}: ComponentPropsWithoutRef<"table"> & { children?: ReactNode }) => (
<MarkdownTable
className={className}
copyLabel={t.clipboard.copyToClipboard}
isLoading={isLoading}
{...props}
>
{children}
</MarkdownTable>
),
...componentsFromProps, ...componentsFromProps,
}; };
}, [componentsFromProps]); }, [componentsFromProps, isLoading, t.clipboard.copyToClipboard]);
if (!content) return null; if (!content) return null;
@ -68,6 +181,7 @@ export function MarkdownContent({
<MessageResponse <MessageResponse
className={className} className={className}
isAnimating={isLoading} isAnimating={isLoading}
controls={{ table: false }}
parseIncompleteMarkdown={!isLoading} parseIncompleteMarkdown={!isLoading}
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}

View File

@ -40,7 +40,6 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
export function MessageGroup({ export function MessageGroup({
className, className,
messages, messages,
@ -87,11 +86,7 @@ export function MessageGroup({
const rehypePlugins = useRehypeSplitWordsIntoSpans(false); const rehypePlugins = useRehypeSplitWordsIntoSpans(false);
const thinkingComponents = useMemo( const thinkingComponents = useMemo(
() => ({ () => ({
code: ({ code: ({ className, children, ...props }: ComponentProps<"code">) => {
className,
children,
...props
}: ComponentProps<"code">) => {
const isBlock = const isBlock =
typeof className === "string" && className.includes("language-"); typeof className === "string" && className.includes("language-");
if (!isBlock) { if (!isBlock) {
@ -126,7 +121,7 @@ export function MessageGroup({
<Button <Button
key="above" key="above"
// 等宋 // 等宋
className="w-full items-start justify-start text-left h-auto! py-4" className="h-auto! w-full items-start justify-start py-4 text-left"
variant="ghost" variant="ghost"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();

View File

@ -34,10 +34,10 @@ import {
stripUploadedFilesTag, stripUploadedFilesTag,
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { dispatchMentionReference } from "@/core/threads/reference-events";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills"; import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { dispatchMentionReference } from "@/core/threads/reference-events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";

View File

@ -43,7 +43,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
) : ( ) : (
<div className="text-primary ml-2 cursor-default font-serif"> <div className="text-primary ml-2 cursor-default font-serif">
{/* TODO: 测试标识 */} {/* TODO: 测试标识 */}
XClaw <span className="text-sm text-[#000000c5]">v3.2.7</span> XClaw <span className="text-sm text-[#000000c5]">v3.2.8</span>
</div> </div>
)} )}
<SidebarTrigger /> <SidebarTrigger />

View File

@ -77,6 +77,9 @@ export const enUS: Translations = {
// Input Box // Input Box
inputBox: { inputBox: {
placeholder: "How can I assist you today?", placeholder: "How can I assist you today?",
welcomePlaceholder:
"Start chatting directly, or describe your task and pick a skill for professional execution.",
chatPlaceholder: "Type “@” to reference files.",
createSkillPrompt: createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?", "We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
sendMessagePrice: sendMessagePrice:

View File

@ -70,6 +70,8 @@ export interface Translations {
inputBox: { inputBox: {
sendMessagePrice: string; sendMessagePrice: string;
placeholder: string; placeholder: string;
welcomePlaceholder: string;
chatPlaceholder: string;
createSkillPrompt: string; createSkillPrompt: string;
addAttachments: string; addAttachments: string;
history: string; history: string;

View File

@ -78,7 +78,9 @@ export const zhCN: Translations = {
// Input Box // Input Box
inputBox: { inputBox: {
placeholder: "可直接对话; 或输入需求并选择skill完成专业任务;“@”可引用文件", placeholder: "可直接对话; 或输入需求并选择skill完成专业任务;",
welcomePlaceholder: "可直接对话; 或输入需求并选择skill完成专业任务。",
chatPlaceholder: "“@”可引用文件。",
createSkillPrompt: createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。", "我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
sendMessagePrice: sendMessagePrice:
@ -259,7 +261,8 @@ export const zhCN: Translations = {
noArtifactSelectedTitle: "未选择生成文件", noArtifactSelectedTitle: "未选择生成文件",
noArtifactSelectedDescription: "请选择一个生成文件以查看详情", noArtifactSelectedDescription: "请选择一个生成文件以查看详情",
exitDialogTitle: "提示", exitDialogTitle: "提示",
exitDialogDescription: "历史记录每七天自动删除,现在将返回欢迎页,是否继续?", exitDialogDescription:
"历史记录每七天自动删除,现在将返回欢迎页,是否继续?",
exitDialogConfirm: "确定", exitDialogConfirm: "确定",
selectedSkillLoadFailed: "技能加载失败", selectedSkillLoadFailed: "技能加载失败",
unknownErrorRetry: "发生了未知错误,请稍后重试。", unknownErrorRetry: "发生了未知错误,请稍后重试。",

View File

@ -17,8 +17,8 @@ import type { UploadedFileInfo } from "../uploads";
import { listUploadedFiles, uploadFiles } from "../uploads"; import { listUploadedFiles, uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api"; import type { UploadTarget } from "../uploads/api";
import { buildFilesForSubmit } from "./submit-files";
import { buildPriorityHintText, composeSubmitText } from "./priority-hint"; import { buildPriorityHintText, composeSubmitText } from "./priority-hint";
import { buildFilesForSubmit } from "./submit-files";
import type { import type {
AgentThread, AgentThread,
AgentThreadContext, AgentThreadContext,
@ -268,8 +268,7 @@ export function useThreadStream({
const now = Date.now(); const now = Date.now();
const lastToast = lastErrorToastRef.current; const lastToast = lastErrorToastRef.current;
if ( if (
lastToast && lastToast?.message === message &&
lastToast.message === message &&
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
) { ) {
return; return;

View File

@ -2,6 +2,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useI18n } from "@/core/i18n/hooks";
import { import {
POST_MESSAGE_TYPES, POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES, RECEIVE_MESSAGE_TYPES,
@ -10,7 +11,6 @@ import {
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
sendToParent, sendToParent,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型 // Skill 数据类型
@ -39,8 +39,20 @@ function parseStoredSkills(raw: string | null): SkillData[] {
.map((item) => { .map((item) => {
if (typeof item !== "object" || item === null) return null; if (typeof item !== "object" || item === null) return null;
const record = item as Record<string, unknown>; const record = item as Record<string, unknown>;
const skillId = String(record.skill_id ?? "").trim(); const rawSkillId = record.skill_id;
const title = String(record.title ?? "").trim(); const skillId =
typeof rawSkillId === "string"
? rawSkillId.trim()
: typeof rawSkillId === "number"
? String(rawSkillId)
: "";
const rawTitle = record.title;
const title =
typeof rawTitle === "string"
? rawTitle.trim()
: typeof rawTitle === "number"
? String(rawTitle)
: "";
if (!skillId || !title) return null; if (!skillId || !title) return null;
return { skill_id: skillId, title }; return { skill_id: skillId, title };
}) })
@ -84,7 +96,11 @@ export function useIframeSkill(
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id"); const threadIdFromQuery = searchParams.get("thread_id");
const threadId = options?.threadId?.trim() || threadIdFromQuery; const threadIdFromOptions = options?.threadId?.trim();
const threadId =
threadIdFromOptions && threadIdFromOptions.length > 0
? threadIdFromOptions
: threadIdFromQuery;
const isChattingFromQuery = searchParams.get("is_chatting"); const isChattingFromQuery = searchParams.get("is_chatting");
const lastThreadIdRef = useRef<string | null>(null); const lastThreadIdRef = useRef<string | null>(null);
@ -316,7 +332,8 @@ export function useIframeSkill(
setSelectedSkills(normalizedSkills); setSelectedSkills(normalizedSkills);
toast.success(t.skills.loadSuccessWithTitle(title), { toast.success(t.skills.loadSuccessWithTitle(title), {
description: result.message || t.skills.createdFiles(result.created_files), description:
result.message || t.skills.createdFiles(result.created_files),
}); });
return true; return true;
@ -325,7 +342,9 @@ export function useIframeSkill(
removeFailedSkills(failedIds); removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap"); toast.dismiss("suggest-skill-bootstrap");
const message = const message =
error instanceof Error ? error.message : t.skills.networkRequestFailed; error instanceof Error
? error.message
: t.skills.networkRequestFailed;
toast.error(t.skills.loadFailedWithTitle(title), { toast.error(t.skills.loadFailedWithTitle(title), {
description: message, description: message,
}); });

View File

@ -2,12 +2,12 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react"; import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useI18n } from "@/core/i18n/hooks";
import { import {
isSelectedSkillMessage, isSelectedSkillMessage,
isSelectedSkillsMessage, isSelectedSkillsMessage,
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { useI18n } from "@/core/i18n/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 技能基础数据 */ /** 技能基础数据 */
@ -105,7 +105,8 @@ export function useSelectedSkillListener({
if (result.success) { if (result.success) {
skillBootstrappedKeyRef.current = initKey; skillBootstrappedKeyRef.current = initKey;
toast.success(t.skills.loadSuccessWithTitle(title), { toast.success(t.skills.loadSuccessWithTitle(title), {
description: result.message || t.skills.createdFiles(result.created_files), description:
result.message || t.skills.createdFiles(result.created_files),
duration: 4000, duration: 4000,
}); });
} else { } else {

View File

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {

View File

@ -77,27 +77,32 @@
"Segoe UI Symbol", "Noto Color Emoji"; "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s; --animate-fade-in: fade-in 1.1s;
@keyframes fade-in { @keyframes fade-in {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
--animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards; --animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
@keyframes fade-in-up { @keyframes fade-in-up {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateY(1rem) scale(1.2); transform: translateY(1rem) scale(1.2);
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
--animate-bouncing: bouncing 0.5s infinite alternate; --animate-bouncing: bouncing 0.5s infinite alternate;
@keyframes bouncing { @keyframes bouncing {
to { to {
opacity: 0.1; opacity: 0.1;
@ -106,11 +111,13 @@
} }
--animate-skeleton-entrance: skeleton-entrance 0.35s ease-out forwards; --animate-skeleton-entrance: skeleton-entrance 0.35s ease-out forwards;
@keyframes skeleton-entrance { @keyframes skeleton-entrance {
0% { 0% {
opacity: 0; opacity: 0;
transform: scaleX(0); transform: scaleX(0);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: scaleX(1); transform: scaleX(1);
@ -118,11 +125,13 @@
} }
--animate-suggestion-in: suggestion-in 0.2s ease-out forwards; --animate-suggestion-in: suggestion-in 0.2s ease-out forwards;
@keyframes suggestion-in { @keyframes suggestion-in {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateY(-1.25rem); transform: translateY(-1.25rem);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@ -130,17 +139,21 @@
} }
--animate-wave: wave 0.6s ease-in-out 2; --animate-wave: wave 0.6s ease-in-out 2;
@keyframes wave { @keyframes wave {
0%, 0%,
100% { 100% {
transform: rotate(0deg); transform: rotate(0deg);
} }
25% { 25% {
transform: rotate(20deg); transform: rotate(20deg);
} }
50% { 50% {
transform: rotate(0deg); transform: rotate(0deg);
} }
75% { 75% {
transform: rotate(20deg); transform: rotate(20deg);
} }
@ -188,36 +201,45 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-tooltip-background: var(--tooltip-background); --color-tooltip-background: var(--tooltip-background);
--animate-aurora: aurora 8s ease-in-out infinite alternate; --animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes aurora { @keyframes aurora {
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;
transform: rotate(-5deg) scale(0.9); transform: rotate(-5deg) scale(0.9);
} }
25% { 25% {
background-position: 50% 100%; background-position: 50% 100%;
transform: rotate(5deg) scale(1.1); transform: rotate(5deg) scale(1.1);
} }
50% { 50% {
background-position: 100% 50%; background-position: 100% 50%;
transform: rotate(-3deg) scale(0.95); transform: rotate(-3deg) scale(0.95);
} }
75% { 75% {
background-position: 50% 0%; background-position: 50% 0%;
transform: rotate(3deg) scale(1.05); transform: rotate(3deg) scale(1.05);
} }
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
transform: rotate(-5deg) scale(0.9); transform: rotate(-5deg) scale(0.9);
} }
} }
--animate-shine: shine var(--duration) infinite linear; --animate-shine: shine var(--duration) infinite linear;
@keyframes shine { @keyframes shine {
0% { 0% {
background-position: 0% 0%; background-position: 0% 0%;
} }
50% { 50% {
background-position: 100% 100%; background-position: 100% 100%;
} }
to { to {
background-position: 0% 0%; background-position: 0% 0%;
} }
@ -308,22 +330,27 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply text-foreground; @apply text-foreground;
} }
.container-md { .container-md {
width: 100%; width: 100%;
@media (width >= 40rem) {
@media (width >=40rem) {
max-width: 40rem; max-width: 40rem;
} }
@media (width >= 48rem) {
@media (width >=48rem) {
max-width: 48rem; max-width: 48rem;
} }
@media (width >= 64rem) {
@media (width >=64rem) {
max-width: 64rem; max-width: 64rem;
} }
@media (width >= 80rem) {
@media (width >=80rem) {
max-width: 80rem; max-width: 80rem;
} }
} }
@ -375,9 +402,11 @@
0% { 0% {
background-position: 0 0; background-position: 0 0;
} }
50% { 50% {
background-position: 400% 0; background-position: 400% 0;
} }
100% { 100% {
background-position: 0 0; background-position: 0 0;
} }
@ -398,13 +427,14 @@
/* Hide scrollbar but keep scroll behavior */ /* Hide scrollbar but keep scroll behavior */
* { * {
scrollbar-width: none; /* Firefox */ scrollbar-width: none;
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none;
} }
/* Chrome, Safari, Opera */ /* Chrome, Safari, Opera */
/* *::-webkit-scrollbar { *::-webkit-scrollbar {
display: none; display: none;
} */ }
:root { :root {
--container-width-xs: calc(var(--spacing) * 72); --container-width-xs: calc(var(--spacing) * 72);
@ -435,6 +465,7 @@ body {
p { p {
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
/* 特别指定,代码块和正文一样的字体 */ /* 特别指定,代码块和正文一样的字体 */
code, code,
kbd, kbd,
@ -443,7 +474,8 @@ pre {
font-family: font-family:
"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important; "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
} }
pre{
pre {
border-radius: 5px; border-radius: 5px;
padding: 12px 16px; padding: 12px 16px;
} }
@ -462,12 +494,14 @@ pre{
/* 二三级标题 - 16px */ /* 二三级标题 - 16px */
[data-streamdown="heading-2"], [data-streamdown="heading-2"],
[data-streamdown="heading-3"],[data-streamdown="heading-4"] { [data-streamdown="heading-3"],
[data-streamdown="heading-4"] {
font-size: calc(16px * var(--zoom-scale)); font-size: calc(16px * var(--zoom-scale));
} }
/* 代码块 - 14px */ /* 代码块 - 14px */
[data-streamdown="code-block"] pre,code { [data-streamdown="code-block"] pre,
code {
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
@ -482,56 +516,69 @@ pre{
[data-streamdown="table-cell"] { [data-streamdown="table-cell"] {
background-color: transparent; background-color: transparent;
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
height:calc(42px * var(--zoom-scale)) ; height: calc(42px * var(--zoom-scale));
} }
[data-streamdown="table-header"] { [data-streamdown="table-header"] {
background: #9c9b9b26; background: #9c9b9b26;
height: calc(50px * var(--zoom-scale)); height: calc(50px * var(--zoom-scale));
} }
[data-streamdown="table-header"] th { [data-streamdown="table-header"] th {
text-align: center; text-align: center;
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
[data-slot="hover-card-trigger"] [data-slot="badge"]{
[data-slot="hover-card-trigger"] [data-slot="badge"] {
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
/* 表格四角圆角:由四个角单元格承担视觉圆角 */ /* 表格四角圆角:由四个角单元格承担视觉圆角 */
[data-streamdown="table-header"] tr:first-child > [data-streamdown="table-header-cell"]:first-child { [data-streamdown="table-header"]
tr:first-child
> [data-streamdown="table-header-cell"]:first-child {
border-top-left-radius: 5px; border-top-left-radius: 5px;
} }
[data-streamdown="table-header"] tr:first-child > [data-streamdown="table-header-cell"]:last-child { [data-streamdown="table-header"]
tr:first-child
> [data-streamdown="table-header-cell"]:last-child {
border-top-right-radius: 5px; border-top-right-radius: 5px;
} }
[data-streamdown="table-body"] tr:first-child td{
[data-streamdown="table-body"] tr:first-child td {
line-height: calc(14px * var(--zoom-scale));
padding-top: calc(20px * var(--zoom-scale)); padding-top: calc(20px * var(--zoom-scale));
} }
/* 行分隔线 */ /* 行分隔线 */
[data-streamdown="table-body"] tr{ /* [data-streamdown="table-body"] tr {
border-bottom: 1px solid var(--border); border-bottom: 1px solid black;
} } */
[data-streamdown="table-body"] tr:last-child > [data-streamdown="table-cell"]:first-child {
[data-streamdown="table-body"]
tr:last-child
> [data-streamdown="table-cell"]:first-child {
border-bottom-left-radius: 5px; border-bottom-left-radius: 5px;
} }
[data-streamdown="table-body"] tr:last-child > [data-streamdown="table-cell"]:last-child { [data-streamdown="table-body"]
tr:last-child
> [data-streamdown="table-cell"]:last-child {
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
} }
[data-streamdown="table-body"] tr:last-child { [data-streamdown="table-body"] tr:last-child td {
padding-top: calc(50px * var(--zoom-scale)); line-height: calc(14px * var(--zoom-scale));
padding-bottom: calc(20px * var(--zoom-scale));
} }
[data-streamdown="table-row"] >[data-streamdown="table-cell"]{
line-height: 14px; [data-streamdown="table-row"] > [data-streamdown="table-cell"] {
vertical-align: top; line-height: calc(42px * var(--zoom-scale));
vertical-align: top;
text-align: center; text-align: center;
} }
.cm-line { .cm-line {
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
white-space: pre-wrap; white-space: pre-wrap;
@ -568,3 +615,7 @@ vertical-align: top;
.pptx-preview-wrap .pptx-preview-wrapper { .pptx-preview-wrap .pptx-preview-wrapper {
height: 100% !important; height: 100% !important;
} }
.ticker-char{
overflow: hidden;
}

View File

@ -164,7 +164,7 @@ export async function rewriteFirstReferenceAsArtifact(
return false; return false;
} }
let fiber = ((element as unknown as Record<string, unknown>)[fiberKey]) as let fiber = (element as unknown as Record<string, unknown>)[fiberKey] as
| { | {
return?: unknown; return?: unknown;
memoizedState?: unknown; memoizedState?: unknown;

View File

@ -5,7 +5,7 @@ import { newChatEntry, openChat, sendMessage } from "./support/chat-helpers";
function logProgress(message: string) { function logProgress(message: string) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
// eslint-disable-next-line no-console
console.log(`[DF-SEC][${timestamp}] ${message}`); console.log(`[DF-SEC][${timestamp}] ${message}`);
} }
@ -21,10 +21,7 @@ function parseForbiddenPrefixes() {
return prefixes; return prefixes;
} }
async function assertNoForbiddenPrefixOnScreen( async function assertNoForbiddenPrefixOnScreen(page: Page, prefixes: string[]) {
page: Page,
prefixes: string[],
) {
if (prefixes.length === 0) return; if (prefixes.length === 0) return;
const leaked = await page.evaluate((items) => { const leaked = await page.evaluate((items) => {
const text = document.body?.innerText ?? ""; const text = document.body?.innerText ?? "";
@ -64,9 +61,7 @@ async function waitForConditionWithLeakCheck({
const now = Date.now(); const now = Date.now();
if (now - lastLogAt >= logEveryMs) { if (now - lastLogAt >= logEveryMs) {
lastLogAt = now; lastLogAt = now;
logProgress( logProgress(`${label}… (${Math.round((now - start) / 1000)}s elapsed)`);
`${label}… (${Math.round((now - start) / 1000)}s elapsed)`,
);
} }
} }
await page.waitForTimeout(stepMs); await page.waitForTimeout(stepMs);
@ -113,7 +108,10 @@ async function waitForArtifactCards({
label, label,
condition: async () => { condition: async () => {
// Cards only render when the panel is open. Try to open opportunistically. // Cards only render when the panel is open. Try to open opportunistically.
if ((await fileList.count()) === 0 || !(await fileList.first().isVisible())) { if (
(await fileList.count()) === 0 ||
!(await fileList.first().isVisible())
) {
await openArtifactsPanelIfPossible(page); await openArtifactsPanelIfPossible(page);
} }
if ((await cards.count()) < minCount) return false; if ((await cards.count()) < minCount) return false;
@ -169,11 +167,7 @@ async function sendMessageSafely({
}); });
await textarea.evaluate((element) => { await textarea.evaluate((element) => {
const target = element as HTMLTextAreaElement; const target = element as HTMLTextAreaElement;
const setter = Object.getOwnPropertyDescriptor( target.value = "";
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(target, "");
target.dispatchEvent(new InputEvent("input", { bubbles: true })); target.dispatchEvent(new InputEvent("input", { bubbles: true }));
}); });
await page.keyboard.insertText(text); await page.keyboard.insertText(text);
@ -241,7 +235,8 @@ test.describe("安全 / 思考块与敏感信息泄露", () => {
timeoutMs: 40_000, timeoutMs: 40_000,
label: "Wait for steps signal", label: "Wait for steps signal",
condition: async () => condition: async () =>
(await stepsSignal.count()) > 0 && (await stepsSignal.first().isVisible()), (await stepsSignal.count()) > 0 &&
(await stepsSignal.first().isVisible()),
}); });
// 按需求40s 内未出现思考块则中断后续检查(标记为 skip // 按需求40s 内未出现思考块则中断后续检查(标记为 skip
@ -256,9 +251,10 @@ test.describe("安全 / 思考块与敏感信息泄露", () => {
minCount: 1, minCount: 1,
label: "Wait for first artifact card", label: "Wait for first artifact card",
}); });
expect(firstArtifacts.ok, "未检测到 artifact-file-card图片可能未生成完成").toBe( expect(
true, firstArtifacts.ok,
); "未检测到 artifact-file-card图片可能未生成完成",
).toBe(true);
logProgress( logProgress(
`First artifact ready (count=${await firstArtifacts.cards.count()}).`, `First artifact ready (count=${await firstArtifacts.cards.count()}).`,
); );
@ -279,7 +275,10 @@ test.describe("安全 / 思考块与敏感信息泄露", () => {
minCount: beforeSecondCount + 1, minCount: beforeSecondCount + 1,
label: "Wait for second artifact card", label: "Wait for second artifact card",
}); });
expect(secondArtifacts.ok, "未检测到新的产物生成artifact 数量未增加)").toBe(true); expect(
secondArtifacts.ok,
"未检测到新的产物生成artifact 数量未增加)",
).toBe(true);
logProgress( logProgress(
`Second artifact ready (count=${await secondArtifacts.cards.count()}).`, `Second artifact ready (count=${await secondArtifacts.cards.count()}).`,
); );

View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Iframe Localhost 2026</title>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #f5f5f5;
}
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
</style>
</head>
<body>
<iframe src="http://localhost:2026" title="localhost-2026"></iframe>
</body>
</html>