Compare commits

...

11 Commits

33 changed files with 1829 additions and 442 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
"use client";
import { Ticker } from "@tombcato/smart-ticker";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -22,6 +23,7 @@ import {
} from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats";
// import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
@ -39,8 +41,7 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { Ticker } from "@tombcato/smart-ticker";
import "@tombcato/smart-ticker/style.css";
import motivationSlogans from "./motivation-slogans.json";
@ -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">

View File

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

View File

@ -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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import ExcelJS from "exceljs";
import JSZip from "jszip";
import {
DownloadIcon,
@ -17,7 +18,6 @@ import {
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import ExcelJS from "exceljs";
import {
Artifact,
@ -73,13 +73,11 @@ let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() {
if (typeof window === "undefined") return Promise.resolve();
if (window.customElements.get("revo-grid")) return Promise.resolve();
if (!revoGridLoaderPromise) {
revoGridLoaderPromise = import("@revolist/revogrid/loader").then(
({ 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}

View File

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

View File

@ -1,8 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import type { ChatStatus } from "ai";
import { Tour } from "antd";
import {
CheckIcon,
GraduationCapIcon,
@ -15,8 +14,11 @@ import {
XIcon,
ZapIcon,
} from "lucide-react";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
@ -25,7 +27,9 @@ import {
type ChangeEvent,
type KeyboardEvent,
type ComponentProps,
type RefObject,
} from "react";
import { toast } from "sonner";
import {
PromptInput,
@ -71,15 +75,14 @@ import { useI18n } from "@/core/i18n/hooks";
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import {
MENTION_REFERENCE_EVENT,
type MentionReferenceEventDetail,
} from "@/core/threads/reference-events";
import type { AgentThreadContext } from "@/core/threads";
import { useUploadedFiles } from "@/core/uploads/hooks";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
ModelSelector,
@ -91,13 +94,33 @@ import {
ModelSelectorTrigger,
} from "../ai-elements/model-selector";
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import { ScrollArea } from "../ui/scroll-area";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
import { useThread } from "./messages/context";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
const MAX_REFERENCES_PER_MESSAGE = 10;
const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1";
type WorkspaceToolButtonProps = ComponentProps<typeof PromptInputButton>;
function WorkspaceToolButton({
className,
...props
}: WorkspaceToolButtonProps) {
return (
<PromptInputButton
className={cn(
// border border-[rgba(0,0,0,0.08)]
"group h-full p-[10px]! rounded-[10px] hover:bg-[#EAE2F5] hover:text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
type MentionCandidate = {
key: string;
@ -214,6 +237,10 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const mentionTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyButtonTourRef = useRef<HTMLDivElement | null>(null);
const attachmentsButtonTourRef = useRef<HTMLDivElement | null>(null);
const skillButtonTourRef = useRef<HTMLDivElement | null>(null);
const suggestionListTourRef = useRef<HTMLDivElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
@ -230,11 +257,97 @@ export function InputBox({
start: number;
end: number;
} | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused =
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused;
const shouldShowSuggestionList =
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill";
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted) {
setIsInputToolsTourReady(false);
return;
}
const frameId = window.requestAnimationFrame(() => {
setIsInputToolsTourReady(
Boolean(
historyButtonTourRef.current &&
attachmentsButtonTourRef.current &&
skillButtonTourRef.current &&
(!shouldShowSuggestionList || suggestionListTourRef.current),
),
);
});
return () => window.cancelAnimationFrame(frameId);
}, [
showWelcomeStyle,
hasSubmitted,
shouldShowSuggestionList,
iframeSkill.isBootstrapping,
iframeSkill.selectedSkills.length,
]);
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) {
setIsInputToolsTourOpen(false);
return;
}
const hasSeenTour = window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY);
if (!hasSeenTour) {
setIsInputToolsTourOpen(true);
}
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady]);
const closeInputToolsTour = useCallback(() => {
window.localStorage.setItem(INPUT_TOOLS_TOUR_SEEN_KEY, "1");
setIsInputToolsTourOpen(false);
}, []);
const inputToolsTourSteps = useMemo(() => {
const baseSteps = [
{
title: "查看历史",
description: "点击这里,可以查看历史会话与文档。",
target: () => historyButtonTourRef.current ?? document.body,
},
{
title: "上传附件",
description: "点击这里,上传参考文档或拟处理的文档。",
target: () => attachmentsButtonTourRef.current ?? document.body,
},
{
title: "选择 Skill",
description: (
<>
skill使skill
<br />
广skill使skill
</>
),
target: () => skillButtonTourRef.current ?? document.body,
},
...(shouldShowSuggestionList
? [
{
title: "试试我吧",
target: () => suggestionListTourRef.current ?? document.body,
},
]
: []),
];
return baseSteps.map((step, index) => ({
...step,
prevButtonProps: { children: "上一步" },
nextButtonProps: {
children: index === baseSteps.length - 1 ? "完成" : "下一步",
},
}));
}, [shouldShowSuggestionList]);
// 点击外部区域时收起输入框
useEffect(() => {
@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,27 +77,32 @@
"Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
--animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(1rem) scale(1.2);
}
100% {
opacity: 1;
}
}
--animate-bouncing: bouncing 0.5s infinite alternate;
@keyframes bouncing {
to {
opacity: 0.1;
@ -106,11 +111,13 @@
}
--animate-skeleton-entrance: skeleton-entrance 0.35s ease-out forwards;
@keyframes skeleton-entrance {
0% {
opacity: 0;
transform: scaleX(0);
}
100% {
opacity: 1;
transform: scaleX(1);
@ -118,11 +125,13 @@
}
--animate-suggestion-in: suggestion-in 0.2s ease-out forwards;
@keyframes suggestion-in {
0% {
opacity: 0;
transform: translateY(-1.25rem);
}
100% {
opacity: 1;
transform: translateY(0);
@ -130,17 +139,21 @@
}
--animate-wave: wave 0.6s ease-in-out 2;
@keyframes wave {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(20deg);
}
@ -188,36 +201,45 @@
--color-sidebar-ring: var(--sidebar-ring);
--color-tooltip-background: var(--tooltip-background);
--animate-aurora: aurora 8s ease-in-out infinite alternate;
@keyframes aurora {
0% {
background-position: 0% 50%;
transform: rotate(-5deg) scale(0.9);
}
25% {
background-position: 50% 100%;
transform: rotate(5deg) scale(1.1);
}
50% {
background-position: 100% 50%;
transform: rotate(-3deg) scale(0.95);
}
75% {
background-position: 50% 0%;
transform: rotate(3deg) scale(1.05);
}
100% {
background-position: 0% 50%;
transform: rotate(-5deg) scale(0.9);
}
}
--animate-shine: shine var(--duration) infinite linear;
@keyframes shine {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
to {
background-position: 0% 0%;
}
@ -308,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;
}

View File

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

View File

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

View File

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