Compare commits
11 Commits
7b53bb0524
...
9cecd24918
| Author | SHA1 | Date |
|---|---|---|
|
|
9cecd24918 | |
|
|
eb45bba7ff | |
|
|
f0d93ab342 | |
|
|
040b107647 | |
|
|
53d383070d | |
|
|
170b5484c9 | |
|
|
d82ac30b93 | |
|
|
a62e65acfe | |
|
|
c52b505354 | |
|
|
d0e0d9e807 | |
|
|
72836322c5 |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"model_profile": "balanced",
|
||||
"commit_docs": true,
|
||||
"commit_docs": false,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": false,
|
||||
|
|
|
|||
|
|
@ -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 形式 PPT)2. 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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";
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ export default function ChatPage() {
|
|||
if (initializedThreadRef.current === safeThreadId) return;
|
||||
initializedThreadRef.current = safeThreadId;
|
||||
void apiClient.threads
|
||||
// TODO: 先注释先删除再创建的逻辑
|
||||
// TODO: 先注释先删除再创建的逻辑
|
||||
// .delete(safeThreadId)
|
||||
// .catch(() => undefined)
|
||||
// .then(() =>
|
||||
|
|
@ -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,20 +499,20 @@ 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
|
||||
data-testid="artifacts-panel-close"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="artifacts-panel-close"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<main className="min-h-0 grow overflow-auto">
|
||||
<ArtifactFileList
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -350,19 +350,19 @@ export function PromptInputAttachment({
|
|||
/>
|
||||
</svg>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
onRemove();
|
||||
return;
|
||||
}
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
aria-label={t.common.removeAttachment}
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
onRemove();
|
||||
return;
|
||||
}
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="8"
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
({ defineCustomElements }) => {
|
||||
defineCustomElements(window);
|
||||
},
|
||||
);
|
||||
}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
@ -286,9 +399,9 @@ export function InputBox({
|
|||
isImage: isImageFilename(filename),
|
||||
previewUrl: threadId
|
||||
? urlOfArtifact({
|
||||
filepath: path,
|
||||
threadId,
|
||||
})
|
||||
filepath: path,
|
||||
threadId,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
|
@ -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,24 +500,27 @@ export function InputBox({
|
|||
form?.requestSubmit();
|
||||
}, []);
|
||||
|
||||
const addMentionReference = useCallback((reference: PromptInputReference) => {
|
||||
setReferences((prev) => {
|
||||
const exists = prev.some(
|
||||
(item) =>
|
||||
item.ref_source === reference.ref_source &&
|
||||
item.path === reference.path &&
|
||||
item.filename === reference.filename,
|
||||
);
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
|
||||
toast.error(t.inputBox.maxReferencesReached);
|
||||
return prev;
|
||||
}
|
||||
return prev.concat(reference);
|
||||
});
|
||||
}, [t.inputBox.maxReferencesReached]);
|
||||
const addMentionReference = useCallback(
|
||||
(reference: PromptInputReference) => {
|
||||
setReferences((prev) => {
|
||||
const exists = prev.some(
|
||||
(item) =>
|
||||
item.ref_source === reference.ref_source &&
|
||||
item.path === reference.path &&
|
||||
item.filename === reference.filename,
|
||||
);
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
|
||||
toast.error(t.inputBox.maxReferencesReached);
|
||||
return prev;
|
||||
}
|
||||
return prev.concat(reference);
|
||||
});
|
||||
},
|
||||
[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,14 +615,15 @@ 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) =>
|
||||
(prev - 1 + filteredMentionCandidates.length) %
|
||||
filteredMentionCandidates.length,
|
||||
setActiveMentionIndex(
|
||||
(prev) =>
|
||||
(prev - 1 + filteredMentionCandidates.length) %
|
||||
filteredMentionCandidates.length,
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
|
@ -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,51 +842,55 @@ 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) => {
|
||||
const detail = [candidate.typeLabel, candidate.pathTail]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={candidate.key}
|
||||
className={cn(
|
||||
"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-candidate-key={candidate.key}
|
||||
data-testid="mention-candidate-item"
|
||||
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
|
||||
onFocus={() => setActiveMentionIndex(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
selectMentionCandidate(candidate);
|
||||
}}
|
||||
>
|
||||
{candidate.isImage && candidate.previewUrl ? (
|
||||
<img
|
||||
src={candidate.previewUrl}
|
||||
alt={candidate.filename}
|
||||
className="h-10 w-10 shrink-0 rounded-md border object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-muted-foreground flex h-10 w-10 shrink-0 items-center justify-center rounded-md border text-[10px] font-semibold">
|
||||
{fileExtensionLabel(candidate.filename)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{candidate.filename}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<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(" · ");
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={candidate.key}
|
||||
className={cn(
|
||||
"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-candidate-key={candidate.key}
|
||||
data-testid="mention-candidate-item"
|
||||
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
|
||||
onFocus={() => setActiveMentionIndex(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
selectMentionCandidate(candidate);
|
||||
}}
|
||||
>
|
||||
{candidate.isImage && candidate.previewUrl ? (
|
||||
<img
|
||||
src={candidate.previewUrl}
|
||||
alt={candidate.filename}
|
||||
className="h-10 w-10 shrink-0 rounded-md border object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted text-muted-foreground flex h-10 w-10 shrink-0 items-center justify-center rounded-md border text-[10px] font-semibold">
|
||||
{fileExtensionLabel(candidate.filename)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{candidate.filename}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
</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!"
|
||||
router={router}
|
||||
threadId={threadIdFromProps}
|
||||
/>}
|
||||
<AddAttachmentsButton className="px-2!" />
|
||||
<IframeSkillDialogButton
|
||||
className="px-2!"
|
||||
selectedSkills={iframeSkill.selectedSkills}
|
||||
isBootstrapping={iframeSkill.isBootstrapping}
|
||||
openSkillDialog={iframeSkill.openSkillDialog}
|
||||
clearSkill={iframeSkill.clearSkill}
|
||||
/>
|
||||
{showWelcomeStyle && (
|
||||
<div ref={historyButtonTourRef} className="shrink-0 h-full">
|
||||
<HistoryButton
|
||||
router={router}
|
||||
threadId={threadIdFromProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
|
||||
<AddAttachmentsButton />
|
||||
</div>
|
||||
<div className="min-w-0 grow basis-0 h-full">
|
||||
<IframeSkillDialogButton
|
||||
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;
|
||||
}) {
|
||||
return (
|
||||
}>(
|
||||
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,28 +1182,28 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
const attachments = usePromptInputAttachments();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.addAttachments}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
<WorkspaceToolButton
|
||||
className={className}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="15"
|
||||
viewBox="0 0 18 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="15"
|
||||
viewBox="0 0 18 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
|
||||
>
|
||||
<path
|
||||
d="M7.05042 7.65254C6.9754 7.72756 6.90039 7.80257 6.90039 7.95258C6.90039 8.02759 6.9754 8.1776 7.05042 8.25262C7.20043 8.40263 7.42545 8.40263 7.57546 8.25262L8.8506 6.97747V10.7279C8.8506 10.9529 9.00061 11.1029 9.22563 11.1029C9.30065 11.1029 9.45066 11.0279 9.52567 11.0279C9.60067 10.9529 9.67568 10.8779 9.67568 10.7279V6.97747L10.9508 8.25262C11.1008 8.40263 11.3259 8.40263 11.4759 8.25262C11.5509 8.1776 11.6259 8.10259 11.6259 7.95258C11.6259 7.87757 11.5509 7.72756 11.4759 7.65254L9.52567 5.70235C9.37564 5.55234 9.15062 5.55234 9.00061 5.70235L7.05042 7.65254Z"
|
||||
fill="#150033"
|
||||
/>
|
||||
<path
|
||||
d="M1.12695 0.5H6.67871C6.87077 0.500077 7.01409 0.574515 7.07324 0.648438L7.09082 0.669922L8.30762 1.88672C8.6222 2.20119 9.01344 2.3681 9.44629 2.36816H16.875C17.2382 2.36842 17.5012 2.63339 17.5 2.99414V13.8848C17.5048 14.2408 17.2454 14.5056 16.8818 14.5059H1.12695C0.764649 14.5057 0.5 14.2401 0.5 13.877V1.12793C0.500049 0.810129 0.702664 0.567404 0.996094 0.511719L1.12695 0.5Z"
|
||||
stroke="#150033"
|
||||
/>
|
||||
</svg>
|
||||
</PromptInputButton>
|
||||
<path
|
||||
d="M7.05042 7.65254C6.9754 7.72756 6.90039 7.80257 6.90039 7.95258C6.90039 8.02759 6.9754 8.1776 7.05042 8.25262C7.20043 8.40263 7.42545 8.40263 7.57546 8.25262L8.8506 6.97747V10.7279C8.8506 10.9529 9.00061 11.1029 9.22563 11.1029C9.30065 11.1029 9.45066 11.0279 9.52567 11.0279C9.60067 10.9529 9.67568 10.8779 9.67568 10.7279V6.97747L10.9508 8.25262C11.1008 8.40263 11.3259 8.40263 11.4759 8.25262C11.5509 8.1776 11.6259 8.10259 11.6259 7.95258C11.6259 7.87757 11.5509 7.72756 11.4759 7.65254L9.52567 5.70235C9.37564 5.55234 9.15062 5.55234 9.00061 5.70235L7.05042 7.65254Z"
|
||||
fill="#150033"
|
||||
/>
|
||||
<path
|
||||
d="M1.12695 0.5H6.67871C6.87077 0.500077 7.01409 0.574515 7.07324 0.648438L7.09082 0.669922L8.30762 1.88672C8.6222 2.20119 9.01344 2.3681 9.44629 2.36816H16.875C17.2382 2.36842 17.5012 2.63339 17.5 2.99414V13.8848C17.5048 14.2408 17.2454 14.5056 16.8818 14.5059H1.12695C0.764649 14.5057 0.5 14.2401 0.5 13.877V1.12793C0.500049 0.810129 0.702664 0.567404 0.996094 0.511719L1.12695 0.5Z"
|
||||
stroke="#150033"
|
||||
/>
|
||||
</svg>
|
||||
</WorkspaceToolButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -1062,47 +1220,48 @@ function HistoryButton({
|
|||
const { t } = useI18n();
|
||||
return (
|
||||
<Tooltip content={t.inputBox.history}>
|
||||
<PromptInputButton
|
||||
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||
onClick={() =>
|
||||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
||||
}
|
||||
<WorkspaceToolButton
|
||||
className={className}
|
||||
onClick={() =>
|
||||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="transition-[stroke] duration-200"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg
|
||||
className="transition-[stroke] duration-200"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
|
||||
cx="9"
|
||||
cy="9"
|
||||
r="8.5"
|
||||
/>
|
||||
<path
|
||||
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
|
||||
d="M9 6V10H12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
</PromptInputButton>
|
||||
<circle
|
||||
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
|
||||
cx="9"
|
||||
cy="9"
|
||||
r="8.5"
|
||||
/>
|
||||
<path
|
||||
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
|
||||
d="M9 6V10H12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</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,24 +1270,26 @@ 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)}
|
||||
onClick={openSkillDialog}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
<div ref={skillButtonRef} className="shrink-0">
|
||||
<WorkspaceToolButton
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={openSkillDialog}
|
||||
>
|
||||
<path
|
||||
d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z"
|
||||
stroke="#150033"
|
||||
/>
|
||||
</svg>
|
||||
</PromptInputButton>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z"
|
||||
stroke="#150033"
|
||||
/>
|
||||
</svg>
|
||||
</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,17 +1359,20 @@ 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
|
||||
? urlOfArtifact({
|
||||
filepath: reference.path,
|
||||
threadId,
|
||||
})
|
||||
filepath: reference.path,
|
||||
threadId,
|
||||
})
|
||||
: 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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -424,22 +424,22 @@ function RichFileCard({
|
|||
/>
|
||||
</a>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="min-w-[120px]">
|
||||
<ContextMenuItem
|
||||
disabled={!canReference}
|
||||
onClick={() => {
|
||||
if (!file.path) return;
|
||||
dispatchMentionReference({
|
||||
threadId,
|
||||
filename: file.filename,
|
||||
path: file.path,
|
||||
ref_source: refSource,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.common.reference}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
<ContextMenuContent className="min-w-[120px]">
|
||||
<ContextMenuItem
|
||||
disabled={!canReference}
|
||||
onClick={() => {
|
||||
if (!file.path) return;
|
||||
dispatchMentionReference({
|
||||
threadId,
|
||||
filename: file.filename,
|
||||
path: file.path,
|
||||
ref_source: refSource,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.common.reference}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export interface Translations {
|
|||
inputBox: {
|
||||
sendMessagePrice: string;
|
||||
placeholder: string;
|
||||
welcomePlaceholder: string;
|
||||
chatPlaceholder: string;
|
||||
createSkillPrompt: string;
|
||||
addAttachments: string;
|
||||
history: string;
|
||||
|
|
|
|||
|
|
@ -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: "发生了未知错误,请稍后重试。",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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,22 +330,27 @@
|
|||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
.container-md {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
|
||||
@media (width >=40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
|
||||
@media (width >=48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
|
||||
@media (width >=64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
|
||||
@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,8 +474,9 @@ pre {
|
|||
font-family:
|
||||
"Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif !important;
|
||||
}
|
||||
pre{
|
||||
border-radius: 5px;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -482,56 +516,69 @@ pre{
|
|||
[data-streamdown="table-cell"] {
|
||||
background-color: transparent;
|
||||
font-size: calc(14px * var(--zoom-scale));
|
||||
height:calc(42px * 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"]{
|
||||
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{
|
||||
|
||||
[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;
|
||||
vertical-align: top;
|
||||
|
||||
[data-streamdown="table-row"] > [data-streamdown="table-cell"] {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()}).`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue