Merge branch 'master-dev' into 'master'

This commit is contained in:
吉鑫升 committed 2026-07-21 14:47:46 +08:00
commit 5436719bfb
39 files changed
+2395 -291

No files matched your search

@@ -0,0 +1,189 @@
"use client";
import { BookOpen, ListTree } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { Badge } from "@/components/ui/badge";
import { reasoningPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import "./wiki.css";
function stripMarkdownToText(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/~~~[\s\S]*?~~~/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^[>\-*+\d.)\s]+/gm, "")
.replace(/[`*_~|]/g, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function getMarkdownExcerpt(markdown: string): string {
const text = stripMarkdownToText(markdown);
if (!text) return "";
return text.length > 150 ? `${text.slice(0, 150)}...` : text;
}
function countReadableWords(markdown: string): number {
const text = stripMarkdownToText(markdown);
if (!text) return 0;
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0;
return cjk + words;
}
function selectorForId(id: string): string {
const css = window.CSS;
if (css?.escape) return `#${css.escape(id)}`;
return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
}
export function ReportMarkdownView({
content,
title,
subtitle,
badgeLabel = "报告阅读",
className,
compactHeader = false,
}: {
content: string;
title?: string;
subtitle?: string;
badgeLabel?: string;
className?: string;
compactHeader?: boolean;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const headings = useMemo(
() => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3),
[anchorResolver],
);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]);
const sectionCount = headings.length;
const wordCount = useMemo(() => countReadableWords(content), [content]);
useEffect(() => {
setActiveHeadingId(headings[0]?.id ?? null);
}, [headings]);
useEffect(() => {
const root = rootRef.current;
if (!root || headings.length === 0) return;
const targets = headings
.map((item) => root.querySelector<HTMLElement>(selectorForId(item.id)))
.filter((item): item is HTMLElement => !!item);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible?.target.id) setActiveHeadingId(visible.target.id);
},
{
root: root.closest("[data-radix-scroll-area-viewport]") ?? null,
rootMargin: "-96px 0px -65% 0px",
threshold: [0, 1],
},
);
targets.forEach((target) => observer.observe(target));
return () => observer.disconnect();
}, [headings, content]);
const scrollToHeading = useCallback((id: string) => {
const root = rootRef.current;
const target =
root?.querySelector<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
setActiveHeadingId(id);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
return (
<div
ref={rootRef}
className={cn("report-markdown-shell grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]", className)}
>
<article className="report-markdown-paper min-w-0 overflow-hidden">
<header className={cn("report-markdown-header", compactHeader && "report-markdown-header-compact")}>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="report-markdown-badge">
<BookOpen className="size-3.5" />
{badgeLabel}
</Badge>
{subtitle && (
<span className="max-w-full truncate text-xs text-slate-500 dark:text-slate-400">
{subtitle}
</span>
)}
</div>
{title && <h1 className="report-markdown-title">{title}</h1>}
{!compactHeader && excerpt && <p className="report-markdown-excerpt">{excerpt}</p>}
<div className="report-markdown-metrics">
<span>{sectionCount || 1} 个章节</span>
<span>约 {wordCount} 字</span>
</div>
</header>
<div className="wiki-markdown report-markdown-body">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</article>
<aside className="hidden min-w-0 lg:block">
<div className="report-markdown-toc">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
<ListTree className="size-4" />
内容目录
</div>
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{sectionCount || 0}
</span>
</div>
{headings.length > 0 ? (
<nav className="report-markdown-toc-list">
{headings.map((item) => (
<button
key={item.id}
type="button"
className={cn(
"report-markdown-toc-item",
activeHeadingId === item.id && "report-markdown-toc-item-active",
item.level === 2 && "report-markdown-toc-level-2",
item.level === 3 && "report-markdown-toc-level-3",
)}
title={item.text}
onClick={() => scrollToHeading(item.id)}
>
{item.text}
</button>
))}
</nav>
) : (
<div className="rounded-md border border-dashed p-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
暂无标题目录
</div>
)}
</div>
</aside>
</div>
);
}
+334
View File
@@ -111,6 +111,340 @@
border-color: #eef1f5;
}
.public-report-markdown {
color: #334155;
font-feature-settings: "kern";
text-rendering: optimizeLegibility;
}
.public-report-markdown h1 {
font-size: 30px;
letter-spacing: 0;
margin-bottom: 24px;
}
.public-report-markdown h2 {
font-size: 22px;
margin-top: 42px;
margin-bottom: 16px;
padding-bottom: 10px;
}
.public-report-markdown h3 {
font-size: 18px;
margin-top: 30px;
margin-bottom: 12px;
}
.public-report-markdown p,
.public-report-markdown li {
font-size: 16px;
line-height: 1.9;
}
.public-report-markdown p {
margin-bottom: 16px;
}
.public-report-markdown ul,
.public-report-markdown ol {
margin: 14px 0 18px;
padding-left: 26px;
}
.public-report-markdown ul {
list-style-type: disc;
}
.public-report-markdown ol {
list-style-type: decimal;
}
.public-report-markdown li {
padding-left: 2px;
}
.public-report-markdown code {
border-radius: 6px;
background: #f1f5f9;
padding: 2px 6px;
color: #be123c;
font-size: 0.92em;
}
.public-report-markdown pre {
margin: 20px 0;
border-radius: 8px;
background: #0f172a;
padding: 16px;
overflow-x: auto;
}
.public-report-markdown pre code {
background: transparent;
padding: 0;
color: inherit;
}
.public-report-markdown table {
display: block;
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e8ecf1;
}
.public-report-markdown th,
.public-report-markdown td {
min-width: 120px;
vertical-align: top;
}
.public-report-markdown blockquote {
margin: 20px 0;
}
.dark .public-report-markdown {
color: #cbd5e1;
}
.dark .public-report-markdown code {
background: #1e293b;
color: #f9a8d4;
}
.dark .public-report-markdown pre {
background: #020617;
}
.dark .public-report-markdown table {
border-color: #334155;
}
.report-markdown-shell {
align-items: start;
padding: 2px;
}
.report-markdown-paper {
border: 1px solid rgba(203, 213, 225, 0.85);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0) 220px),
#ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 18px 44px rgba(15, 23, 42, 0.07);
}
.report-markdown-header {
border-bottom: 1px solid #e8edf4;
padding: 26px 34px 22px;
}
.report-markdown-header-compact {
padding-bottom: 18px;
}
.report-markdown-badge {
gap: 6px;
border: 1px solid #dbe3ef;
background: #ffffff;
color: #475569;
}
.report-markdown-title {
margin: 14px 0 0;
color: #0f172a;
font-size: 26px;
font-weight: 700;
line-height: 1.35;
letter-spacing: 0;
}
.report-markdown-excerpt {
margin: 14px 0 0;
max-width: 840px;
color: #64748b;
font-size: 14px;
line-height: 1.8;
}
.report-markdown-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
color: #64748b;
font-size: 12px;
}
.report-markdown-metrics span {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.78);
padding: 4px 8px;
}
.report-markdown-body {
max-width: 920px;
margin: 0 auto;
padding: 32px 42px 44px;
}
.report-markdown-body h1 {
font-size: 28px;
}
.report-markdown-body h2 {
position: relative;
margin-top: 44px;
border-bottom-color: #e5edf7;
font-size: 22px;
}
.report-markdown-body h2::before {
content: "";
position: absolute;
left: -16px;
top: 0.34em;
width: 4px;
height: 1.05em;
border-radius: 999px;
background: #2563eb;
}
.report-markdown-body h3 {
color: #1e293b;
font-size: 18px;
}
.report-markdown-body p,
.report-markdown-body li {
font-size: 16px;
line-height: 1.92;
}
.report-markdown-body blockquote {
border-left-color: #2563eb;
background: #f8fbff;
}
.report-markdown-body table {
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
.report-markdown-toc {
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow: hidden;
border: 1px solid rgba(203, 213, 225, 0.82);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
padding: 14px;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10px);
}
.report-markdown-toc-list {
max-height: calc(100vh - 128px);
overflow: auto;
padding-right: 4px;
}
.report-markdown-toc-item {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-left: 2px solid transparent;
border-radius: 6px;
padding: 7px 8px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
text-align: left;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.report-markdown-toc-item:hover {
background: #f1f5f9;
color: #0f172a;
}
.report-markdown-toc-item-active {
border-left-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.report-markdown-toc-level-2 {
padding-left: 18px;
}
.report-markdown-toc-level-3 {
padding-left: 30px;
color: #94a3b8;
}
.dark .report-markdown-paper {
border-color: #1f2937;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 0) 220px),
#020617;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.24);
}
.dark .report-markdown-header {
border-bottom-color: #1e293b;
}
.dark .report-markdown-badge,
.dark .report-markdown-metrics span {
border-color: #334155;
background: #0f172a;
color: #cbd5e1;
}
.dark .report-markdown-title {
color: #f8fafc;
}
.dark .report-markdown-excerpt,
.dark .report-markdown-metrics {
color: #94a3b8;
}
.dark .report-markdown-body h3 {
color: #e2e8f0;
}
.dark .report-markdown-body h2::before {
background: #60a5fa;
}
.dark .report-markdown-body blockquote {
background: #0f172a;
border-left-color: #60a5fa;
}
.dark .report-markdown-toc {
border-color: #1f2937;
background: rgba(2, 6, 23, 0.88);
}
.dark .report-markdown-toc-item:hover {
background: #0f172a;
color: #f8fafc;
}
.dark .report-markdown-toc-item-active {
background: rgba(37, 99, 235, 0.16);
color: #93c5fd;
}
/* Anchor target offset so headings clear the floating "目录" pill. */
.wiki-markdown h1,
.wiki-markdown h2,
@@ -93,43 +93,47 @@ export function AgentSelector({
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{iconMode ? (
<Tooltip content={iconTooltipLabel}>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
className={cn(
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
selectedAgent && "text-amber-600 hover:text-amber-700",
selectedAgent && iconActiveClassName,
iconButtonClassName,
)}
>
<BotIcon className="size-4" />
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-2.5" />
</span>
)}
</Button>
</Tooltip>
) : (
{iconMode ? (
<Tooltip content={iconTooltipLabel}>
<div>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
className={cn(
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
selectedAgent && "text-amber-600 hover:text-amber-700",
selectedAgent && iconActiveClassName,
iconButtonClassName,
)}
>
<BotIcon className="size-4" />
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-2.5" />
</span>
)}
</Button>
</DialogTrigger>
</div>
</Tooltip>
) : (
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
@@ -167,8 +171,8 @@ export function AgentSelector({
</span>
)}
</PromptInputButton>
)}
</DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
@@ -45,11 +45,32 @@ function skillLabel(skill: Skill) {
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
const haystack = [
skill.name,
skill.name_zh,
skill.description,
skill.detail,
skill.category,
skill.license,
skill.source,
skill.state,
skill.owner_username,
skill.created_at,
skill.last_activity_at,
skill.use_count != null ? `使用 ${skill.use_count}` : null,
skill.view_count != null ? `查看 ${skill.view_count}` : null,
skill.patch_count != null ? `变迁 ${skill.patch_count}` : null,
...(skill.tags ?? []).flatMap((tag) => [
tag.name,
tag.description,
tag.color,
tag.usage_count != null ? `标签使用 ${tag.usage_count}` : null,
]),
]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(" ")
.toLowerCase();
return haystack.includes(q);
}
export function SkillTransferSelector({
@@ -159,7 +180,7 @@ export function SkillTransferSelector({
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索全部技能(中英文名称、描述)…"
placeholder="搜索技能名称、描述、分类、标签、变迁..."
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
@@ -239,7 +260,7 @@ export function SkillTransferSelector({
{/* Two-column transfer panes */}
<div className="grid grid-cols-2 gap-3">
<SkillPane
title="可用技能"
title="未勾选技能"
count={availableSkills.length}
items={availableView}
variant="available"
@@ -248,10 +269,10 @@ export function SkillTransferSelector({
onPageChange={setAvailablePage}
onPick={add}
onViewDetail={onViewDetail}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部勾选"}
/>
<SkillPane
title="已启用技能"
title="已勾选技能"
count={selectedSkills.length}
items={selectedView}
variant="selected"
@@ -260,7 +281,7 @@ export function SkillTransferSelector({
onPageChange={setSelectedPage}
onPick={remove}
onViewDetail={onViewDetail}
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
emptyHint={query ? "没有匹配的已选技能" : "尚未勾选任何技能"}
/>
</div>
</div>
@@ -470,11 +470,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
@@ -497,11 +497,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
{publisher && (
<div className="flex items-center gap-2">
</div>
}
/>
{!isLoading && !error && (
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
共 {orderedSkills.length} 个技能
</div>
)}
{publisher && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">筛选发布者:</span>
<button
type="button"
@@ -7,12 +7,11 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
SparklesIcon,
@@ -219,14 +218,14 @@ export function WorkspaceNavMenu() {
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{/* {isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)} */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}