Compare commits

...

11 Commits

33 changed files with 1829 additions and 442 deletions

View File

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

View File

@ -10,12 +10,14 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from typing import Any
from fastapi import HTTPException, Request
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 deerflow.runtime import (
@ -32,6 +34,17 @@ from deerflow.runtime import (
)
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
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"
@ -282,6 +426,7 @@ async def start_run(
agent_factory = resolve_agent_factory(body.assistant_id)
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)
if "configurable" in config and isinstance(config["configurable"], dict):

View File

@ -3,6 +3,9 @@
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
from langchain_core.messages import HumanMessage
def test_format_sse_basic():
@ -81,6 +84,55 @@ def test_normalize_input_passthrough():
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():
from app.gateway.services import build_run_config

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -36,7 +36,7 @@ export const Message = ({
"group flex w-full flex-col gap-2",
from === "user"
? 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,
)}
{...props}

View File

@ -1064,7 +1064,7 @@ export const PromptInputTools = ({
className,
...props
}: 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>;

View File

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

View File

@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
@ -17,7 +17,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
);
}
function ContextMenuGroup({
@ -25,7 +25,7 @@ function ContextMenuGroup({
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
);
}
function ContextMenuPortal({
@ -33,13 +33,13 @@ function ContextMenuPortal({
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
);
}
function ContextMenuSub({
...props
}: 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({
@ -50,7 +50,7 @@ function ContextMenuRadioGroup({
data-slot="context-menu-radio-group"
{...props}
/>
)
);
}
function ContextMenuSubTrigger({
@ -59,22 +59,22 @@ function ContextMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
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",
className
"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,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
);
}
function ContextMenuSubContent({
@ -85,12 +85,12 @@ function ContextMenuSubContent({
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
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",
className
"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,
)}
{...props}
/>
)
);
}
function ContextMenuContent({
@ -102,13 +102,13 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
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",
className
"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,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
);
}
function ContextMenuItem({
@ -117,8 +117,8 @@ function ContextMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
@ -126,12 +126,12 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
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!",
className
"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,
)}
{...props}
/>
)
);
}
function ContextMenuCheckboxItem({
@ -144,8 +144,8 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
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",
className
"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,
)}
checked={checked}
{...props}
@ -157,7 +157,7 @@ function ContextMenuCheckboxItem({
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
);
}
function ContextMenuRadioItem({
@ -169,8 +169,8 @@ function ContextMenuRadioItem({
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
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",
className
"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,
)}
{...props}
>
@ -181,7 +181,7 @@ function ContextMenuRadioItem({
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
);
}
function ContextMenuLabel({
@ -189,19 +189,19 @@ function ContextMenuLabel({
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8",
className
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
)
);
}
function ContextMenuSeparator({
@ -211,10 +211,10 @@ function ContextMenuSeparator({
return (
<ContextMenuPrimitive.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}
/>
)
);
}
function ContextMenuShortcut({
@ -225,12 +225,12 @@ function ContextMenuShortcut({
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
)
);
}
export {
@ -249,4 +249,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};

View File

@ -81,7 +81,9 @@ export function DropdownSelector<T extends string>({
}
>
<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 />}
</span>
</DropdownMenuTrigger>
@ -98,7 +100,8 @@ export function DropdownSelector<T extends string>({
value={option.value}
title={option.label}
>
{truncateMiddle(option.label)}
{/* {truncateMiddle(option.label,50)} */}
{truncateMiddle("hfiqwertyuiopasdfghjklxcvbnm.html", 20)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>

View File

@ -37,7 +37,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
}
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: {
align: {
@ -46,9 +46,9 @@ const inputGroupAddonVariants = cva(
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"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":
"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: {

View File

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

View File

@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Slider({
className,
@ -20,8 +20,8 @@ function Slider({
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
@ -32,20 +32,20 @@ function Slider({
max={max}
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",
className
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
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
data-slot="slider-range"
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>
@ -53,11 +53,11 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
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>
)
);
}
export { Slider }
export { Slider };

View File

@ -1,3 +1,4 @@
import ExcelJS from "exceljs";
import JSZip from "jszip";
import {
DownloadIcon,
@ -17,7 +18,6 @@ import {
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import ExcelJS from "exceljs";
import {
Artifact,
@ -73,13 +73,11 @@ let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() {
if (typeof window === "undefined") 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(window);
},
);
}
return revoGridLoaderPromise;
}
@ -99,18 +97,45 @@ function toGridCellText(cell: ExcelJS.Cell): string {
const value = cell.value;
if (value == null) return "";
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 ("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) {
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) {
return String(value.hyperlink);
const hyperlink = value.hyperlink;
if (typeof hyperlink === "string") {
return hyperlink;
}
}
return String(value);
}
return "";
}
function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData {
@ -398,8 +423,8 @@ export function ArtifactFileDetail({
className,
)}
>
<ArtifactHeader className="grid grid-cols-12 gap-3">
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
<ArtifactHeader className="grid grid-cols-24">
<div className="col-span-7 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && (
<ToggleGroup
type="single"
@ -464,11 +489,11 @@ export function ArtifactFileDetail({
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
) : null}
</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>
{isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
{truncateMiddle(getFileName(filepath), 50)}
{truncateMiddle(getFileName(filepath), 20)}
</div>
) : (
<DropdownSelector
@ -479,7 +504,7 @@ export function ArtifactFileDetail({
)}
</ArtifactTitle>
</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>
{isCodeFile && (
<ArtifactAction
@ -1702,12 +1727,18 @@ export const ArtifactZoomSelector = ({
</svg>
</button>
</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">
<span className="text-muted-foreground text-xs">
{ZOOM_LEVELS[0]}%
</span>
<span className="text-foreground text-xs font-medium">{value}%</span>
<span className="text-foreground text-xs font-medium">
{value}%
</span>
</div>
<Slider
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">
<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>
<main className="min-h-0 grow">
<ArtifactFileList

View File

@ -1,8 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import type { ChatStatus } from "ai";
import { Tour } from "antd";
import {
CheckIcon,
GraduationCapIcon,
@ -15,8 +14,11 @@ import {
XIcon,
ZapIcon,
} 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 {
forwardRef,
useCallback,
useEffect,
useMemo,
@ -25,7 +27,9 @@ import {
type ChangeEvent,
type KeyboardEvent,
type ComponentProps,
type RefObject,
} from "react";
import { toast } from "sonner";
import {
PromptInput,
@ -71,15 +75,14 @@ import { useI18n } from "@/core/i18n/hooks";
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import {
MENTION_REFERENCE_EVENT,
type MentionReferenceEventDetail,
} from "@/core/threads/reference-events";
import type { AgentThreadContext } from "@/core/threads";
import { useUploadedFiles } from "@/core/uploads/hooks";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
ModelSelector,
@ -91,13 +94,33 @@ import {
ModelSelectorTrigger,
} from "../ai-elements/model-selector";
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 { 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 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 = {
key: string;
@ -214,6 +237,10 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | 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 [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
@ -230,11 +257,97 @@ export function InputBox({
start: number;
end: number;
} | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused =
((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(() => {
@ -372,7 +485,14 @@ export function InputBox({
});
setReferences([]);
},
[showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills],
[
showWelcomeStyle,
onSubmit,
onStop,
references,
status,
iframeSkill.selectedSkills,
],
);
const requestFormSubmit = useCallback(() => {
@ -380,7 +500,8 @@ export function InputBox({
form?.requestSubmit();
}, []);
const addMentionReference = useCallback((reference: PromptInputReference) => {
const addMentionReference = useCallback(
(reference: PromptInputReference) => {
setReferences((prev) => {
const exists = prev.some(
(item) =>
@ -397,7 +518,9 @@ export function InputBox({
}
return prev.concat(reference);
});
}, [t.inputBox.maxReferencesReached]);
},
[t.inputBox.maxReferencesReached],
);
const selectMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
@ -432,7 +555,7 @@ export function InputBox({
useEffect(() => {
const onMentionReference = (event: Event) => {
const detail = (event as CustomEvent<MentionReferenceEventDetail>).detail;
if (!detail || detail.threadId !== threadIdFromProps) {
if (detail?.threadId !== threadIdFromProps) {
return;
}
addMentionReference({
@ -492,12 +615,13 @@ export function InputBox({
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex((prev) =>
(prev + 1) % filteredMentionCandidates.length,
setActiveMentionIndex(
(prev) => (prev + 1) % filteredMentionCandidates.length,
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex((prev) =>
setActiveMentionIndex(
(prev) =>
(prev - 1 + filteredMentionCandidates.length) %
filteredMentionCandidates.length,
);
@ -603,6 +727,21 @@ export function InputBox({
}}
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
references={references}
threadId={threadId}
@ -658,7 +797,11 @@ export function InputBox({
!effectiveIsFocused && "h-[80px] py-0 leading-20",
)}
disabled={isInputDisabled}
placeholder={t.inputBox.placeholder}
placeholder={
showWelcomeStyle
? t.inputBox.welcomePlaceholder
: t.inputBox.chatPlaceholder
}
autoFocus={autoFocus}
defaultValue={initialValue}
onFocus={() => setIsFocused(true)}
@ -688,7 +831,7 @@ export function InputBox({
align="start"
side="top"
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"
onCloseAutoFocus={(event) => {
event.preventDefault();
@ -699,8 +842,9 @@ export function InputBox({
{t.inputBox.addReference}
</DropdownMenuLabel>
<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">
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => {
<DropdownMenuGroup className="flex max-h-[480px] flex-col gap-[10px] px-0 pt-[20px]">
<ScrollArea className="h-[480px]" data-state="hidden">
{filteredMentionCandidates.map((candidate, index) => {
const detail = [candidate.typeLabel, candidate.pathTail]
.filter(Boolean)
.join(" · ");
@ -711,7 +855,9 @@ export function InputBox({
"flex items-center justify-between gap-3 rounded-md px-2 py-2 text-left",
index === activeMentionIndex && "bg-accent",
)}
data-active={index === activeMentionIndex ? "true" : "false"}
data-active={
index === activeMentionIndex ? "true" : "false"
}
data-candidate-key={candidate.key}
data-testid="mention-candidate-item"
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
@ -744,6 +890,7 @@ export function InputBox({
</DropdownMenuItem>
);
})}
</ScrollArea>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@ -764,7 +911,7 @@ export function InputBox({
"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
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@ -774,19 +921,27 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{showWelcomeStyle && <HistoryButton
className="px-2!"
{showWelcomeStyle && (
<div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton
router={router}
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
className="px-2!"
skillButtonRef={skillButtonTourRef}
selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill}
/>
</div>
{/* <div className="h-[40px] w-[140px] shrink-0" aria-hidden="true" /> */}
{/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools>
@ -823,7 +978,7 @@ export function InputBox({
</ModelSelector> */}
<PromptInputTools>
{/* 占位符 */}
<div className="w-[150px]"></div>
<div className="w-[150px] h-[40px]"></div>
</PromptInputTools>
</PromptInputFooter>
<PromptInputSubmit
@ -834,10 +989,9 @@ export function InputBox({
/>
</PromptInput>
{showWelcomeStyle &&
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
{shouldShowSuggestionList && (
<SuggestionListContainer
ref={suggestionListTourRef}
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping}
/>
@ -904,25 +1058,29 @@ export function InputBox({
}
// SuggestionList 容器
function SuggestionListContainer({
bootstrapAndLockSkills,
isBootstrapping,
}: {
const SuggestionListContainer = forwardRef<HTMLDivElement, {
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) {
}>(
function SuggestionListContainer(
{ bootstrapAndLockSkills, isBootstrapping },
ref,
) {
return (
<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
bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping}
/>
</div>
</div>
);
},
);
}
// 快速选择skillbutton
function SuggestionList({
@ -1024,8 +1182,8 @@ function AddAttachmentsButton({ className }: { className?: string }) {
const attachments = usePromptInputAttachments();
return (
<Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
<WorkspaceToolButton
className={className}
onClick={() => attachments.openFileDialog()}
>
<svg
@ -1045,7 +1203,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
stroke="#150033"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</Tooltip>
);
}
@ -1062,8 +1220,8 @@ function HistoryButton({
const { t } = useI18n();
return (
<Tooltip content={t.inputBox.history}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
<WorkspaceToolButton
className={className}
onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
}
@ -1089,20 +1247,21 @@ function HistoryButton({
strokeLinejoin="round"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</Tooltip>
);
}
// 启动iframeSkillDialog
function IframeSkillDialogButton({
className,
skillButtonRef,
selectedSkills,
isBootstrapping,
openSkillDialog,
clearSkill,
}: {
className?: string;
skillButtonRef?: RefObject<HTMLDivElement | null>;
selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean;
openSkillDialog: () => void;
@ -1111,10 +1270,11 @@ function IframeSkillDialogButton({
const { t } = useI18n();
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}>
<PromptInputButton
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
<div ref={skillButtonRef} className="shrink-0">
<WorkspaceToolButton
className={cn("shrink-0", className)}
onClick={openSkillDialog}
>
<svg
@ -1128,7 +1288,8 @@ function IframeSkillDialogButton({
stroke="#150033"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</div>
</Tooltip>
{isBootstrapping ? (
<Tag className="bg-background text-muted-foreground gap-2 border">
@ -1138,7 +1299,7 @@ function IframeSkillDialogButton({
) : null}
{!isBootstrapping && selectedSkills.length > 0 ? (
<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) => {
if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY;
@ -1198,7 +1359,10 @@ function AttachmentPreviewBar({
</PromptInputAttachments>
)}
{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) => {
const referenceUrl =
threadId && reference.path
@ -1208,7 +1372,7 @@ function AttachmentPreviewBar({
})
: null;
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 mediaType = extension
? extension === "jpg"

View File

@ -1,14 +1,20 @@
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react";
import type {
AnchorHTMLAttributes,
ComponentPropsWithoutRef,
ReactNode,
} from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { cn, copyToClipboard } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
@ -25,6 +31,97 @@ export type MarkdownContentProps = {
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. */
export function MarkdownContent({
content,
@ -34,6 +131,8 @@ export function MarkdownContent({
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const { t } = useI18n();
const components = useMemo(() => {
return {
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, isLoading, t.clipboard.copyToClipboard]);
if (!content) return null;
@ -68,6 +181,7 @@ export function MarkdownContent({
<MessageResponse
className={className}
isAnimating={isLoading}
controls={{ table: false }}
parseIncompleteMarkdown={!isLoading}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}

View File

@ -40,7 +40,6 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content";
export function MessageGroup({
className,
messages,
@ -87,11 +86,7 @@ export function MessageGroup({
const rehypePlugins = useRehypeSplitWordsIntoSpans(false);
const thinkingComponents = useMemo(
() => ({
code: ({
className,
children,
...props
}: ComponentProps<"code">) => {
code: ({ className, children, ...props }: ComponentProps<"code">) => {
const isBlock =
typeof className === "string" && className.includes("language-");
if (!isBlock) {
@ -126,7 +121,7 @@ export function MessageGroup({
<Button
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"
onClick={(event) => {
event.stopPropagation();

View File

@ -34,10 +34,10 @@ import {
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { dispatchMentionReference } from "@/core/threads/reference-events";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { materializeSkillYaml } from "@/core/skills";
import { humanMessagePlugins } from "@/core/streamdown";
import { dispatchMentionReference } from "@/core/threads/reference-events";
import { cn } from "@/lib/utils";
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">
{/* TODO: 测试标识 */}
XClaw <span className="text-sm text-[#000000c5]">v3.2.7</span>
XClaw <span className="text-sm text-[#000000c5]">v3.2.8</span>
</div>
)}
<SidebarTrigger />

View File

@ -77,6 +77,9 @@ export const enUS: Translations = {
// Input Box
inputBox: {
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:
"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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -164,7 +164,7 @@ export async function rewriteFirstReferenceAsArtifact(
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;
memoizedState?: unknown;

View File

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