代码修改
This commit is contained in:
1 parent
8ed3b0eb91
commit
786ddaa38f
9 files changed
+293
-173
No files matched your search
@@ -1,6 +1,16 @@
|
||||
.sidebar-contnet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 98px;
|
||||
height: calc(100vh - 4rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
width: 98px;
|
||||
min-height: 986px;
|
||||
min-height: calc(100% - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
@@ -11,16 +21,30 @@
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
position: relative;
|
||||
height: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
.item-nav-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-nav-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-nav-group:first-child .item-nav {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.item-nav {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background: #ffffff1a;
|
||||
border-color: #fefafa4d;
|
||||
margin-top: 13px;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-weight: 550;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
@@ -35,9 +59,46 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav:hover {
|
||||
background: rgba(85, 166, 254, 0.12);
|
||||
.item-nav:hover,
|
||||
.item-nav-active,
|
||||
.item-nav-hovered {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(134.25deg,
|
||||
rgba(0, 98, 217, 0.59) 2.28%,
|
||||
rgba(0, 98, 217, 0.3) 99.59%) !important;
|
||||
background-size: cover;
|
||||
border: 1px solid;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav:hover::before,
|
||||
.item-nav-active::before,
|
||||
.item-nav-hovered::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-left: 10px solid rgba(0, 98, 217, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item-nav:hover::after,
|
||||
.item-nav-active::after,
|
||||
.item-nav-hovered::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
width: 77px;
|
||||
height: 45px;
|
||||
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.item-nav__label {
|
||||
@@ -52,53 +113,47 @@
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
min-height: 970px;
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
min-height: calc(100vh - 4rem - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(2px);
|
||||
position: absolute;
|
||||
left: 97px;
|
||||
left: 108px;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
padding: 14px 13px 18px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item-nav-active {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(134.25deg,
|
||||
rgba(0, 98, 217, 0.59) 2.28%,
|
||||
rgba(0, 98, 217, 0.3) 99.59%) !important;
|
||||
background-size: cover;
|
||||
border: 1px solid;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-left: 10px solid rgba(0, 98, 217, 0.45);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
.item-nav-active::after {
|
||||
|
||||
.item-nav-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
width: 77px;
|
||||
height: 45px;
|
||||
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
|
||||
background-size: cover;
|
||||
left: -30px;
|
||||
top: 0;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.item-nav-hover-bridge {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
top: 0;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.item-nav-content--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-flyout-list {
|
||||
@@ -140,7 +195,7 @@
|
||||
padding: 3px 0;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
@@ -37,18 +37,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import './page-sdebar.css'
|
||||
|
||||
type MenuItem = {
|
||||
@@ -60,13 +49,6 @@ type MenuItem = {
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
type PageSidebarDarkProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
@@ -158,9 +140,9 @@ const menuItems: MenuItem[] = [
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo, path: "/page/strategy/task-management/effect-task-pg" },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo, path: "/page/strategy/task-management/effect-action-pg" },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo, path: "/page/strategy/task-management/effect-info-pg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -168,9 +150,9 @@ const menuItems: MenuItem[] = [
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-mechanism" },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-plan" },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-zsty" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -278,35 +260,49 @@ function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[]
|
||||
);
|
||||
}
|
||||
|
||||
export function PageSidebarDark({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarDarkProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
export function PageSidebarDark() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useMemo(
|
||||
() => filterMenuItemsByRole(menuItems, !!isAdmin),
|
||||
[isAdmin],
|
||||
);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
const handleNavHoverEnter = (id: string) => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}
|
||||
setHoveredTopId(id);
|
||||
};
|
||||
|
||||
const scheduleNavPanelClose = () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
}
|
||||
hoverCloseTimerRef.current = window.setTimeout(() => {
|
||||
setHoveredTopId(null);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}, 180);
|
||||
};
|
||||
|
||||
const closeNavPanel = () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}
|
||||
setHoveredTopId(null);
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
@@ -317,18 +313,21 @@ export function PageSidebarDark({
|
||||
return item.children?.some(hasActiveChild) ?? false;
|
||||
};
|
||||
|
||||
const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0];
|
||||
const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center");
|
||||
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
|
||||
const [activeTopId, setActiveTopId] = useState<string>(
|
||||
() => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center",
|
||||
);
|
||||
const [hoveredTopId, setHoveredTopId] = useState<string | null>(null);
|
||||
const hoverCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
|
||||
if (matchedTopItem) {
|
||||
setActiveTopId(matchedTopItem.id);
|
||||
}
|
||||
closeNavPanel();
|
||||
}, [pathname, visibleMenuItems]);
|
||||
|
||||
const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem;
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -345,6 +344,7 @@ export function PageSidebarDark({
|
||||
setActiveTopId(item.id);
|
||||
|
||||
if (!item.children?.length && item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
@@ -356,6 +356,7 @@ export function PageSidebarDark({
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
@@ -387,34 +388,37 @@ export function PageSidebarDark({
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
|
||||
<div className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
<div className="item-nav-content">
|
||||
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
|
||||
<aside className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
{visibleMenuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="item-nav-group"
|
||||
onMouseEnter={() => handleNavHoverEnter(item.id)}
|
||||
onMouseLeave={scheduleNavPanelClose}
|
||||
>
|
||||
{item.children?.length ? (
|
||||
<div
|
||||
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
|
||||
>
|
||||
{renderFlyoutMenu(item.children)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="item-nav-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</button>
|
||||
{item.children?.length && hoveredTopId === item.id ? (
|
||||
<span className="item-nav-hover-bridge" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{visibleMenuItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -448,8 +448,15 @@ export function AIWritingPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
|
||||
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
|
||||
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
|
||||
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
|
||||
{state.status === 'researching' && (
|
||||
state.skillSelectStreamingText
|
||||
|| state.skillSelectThinkingText
|
||||
|| (state.skillSearchSteps.some((s) => s.type === 'match_skills')
|
||||
&& !state.skillSearchSteps.some((s) => s.type === 'select_skills'))
|
||||
) && (
|
||||
<SkillSelectStreamingView
|
||||
text={state.skillSelectStreamingText}
|
||||
thinkingText={state.skillSelectThinkingText}
|
||||
@@ -467,8 +474,9 @@ export function AIWritingPanel({
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
|
||||
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */
|
||||
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
|
||||
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
@@ -7,25 +7,22 @@ interface SkillSelectStreamingViewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从流式 JSON 文本里增量提取已经识别出的技能名。
|
||||
* 从流式 JSON 文本里增量提取已识别的技能名(仅取**已闭合**的字符串项)。
|
||||
*
|
||||
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`,
|
||||
* 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串:
|
||||
* - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容
|
||||
* - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."`
|
||||
* - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来
|
||||
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`。
|
||||
* 不等整段 JSON 解析完,逐字符扫描:
|
||||
* - 定位 `"selected_skills":\s*[`
|
||||
* - 在该位置后到 `]`(或文本末尾)之间逐个匹配 `"..."`
|
||||
* - 只取已闭合的字符串(结尾必须是 `"`),保证不显示正在打字的半截名字
|
||||
*/
|
||||
function extractStreamingSkillNames(text: string): string[] {
|
||||
if (!text) return [];
|
||||
// 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白)
|
||||
const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/);
|
||||
if (!keyMatch) return [];
|
||||
const start = keyMatch.index! + keyMatch[0].length;
|
||||
// 截取数组段(找到对应的 ],或者文本结尾)
|
||||
const rest = text.slice(start);
|
||||
const endIdx = rest.indexOf(']');
|
||||
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
|
||||
// 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号
|
||||
const names: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -37,32 +34,46 @@ function extractStreamingSkillNames(text: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能选择 LLM 流式打字机视图。
|
||||
* 从流式 JSON 文本里提取 `reason` 字段当前已输出的内容。
|
||||
*
|
||||
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
|
||||
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
|
||||
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
|
||||
* 和 skill name 不同,reason 是流式叙述文本,**未闭合也要展示**(带闪烁光标),
|
||||
* 让用户看到 LLM 正在解释选择理由。
|
||||
*/
|
||||
function extractStreamingReason(text: string): { value: string; closed: boolean } {
|
||||
if (!text) return { value: '', closed: false };
|
||||
// 匹配 "reason": "..." 直到下一个未转义的 " 或文本末尾
|
||||
const m = text.match(/["']?reason["']?\s*:\s*"((?:[^"\\]|\\.)*?)("|$)/);
|
||||
if (!m) return { value: '', closed: false };
|
||||
const raw = m[1];
|
||||
const closed = m[2] === '"';
|
||||
// 反转义常见序列
|
||||
const value = raw
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
return { value, closed };
|
||||
}
|
||||
|
||||
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
// 实时从流式 JSON 中增量解析出已识别的技能名
|
||||
// 增量解析:候选技能 + 选择原因
|
||||
const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}, [text]);
|
||||
const reason = useMemo(() => extractStreamingReason(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
// 步骤预览始终渲染(挂载即显示),让父组件控制挂载时机;
|
||||
// 这样首个空 chunk 到达时用户就能看到"等待模型输出"占位 + 跳动光标
|
||||
const showStepPreview = true;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{/* 思考过程(amber,可折叠) */}
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
@@ -94,15 +105,16 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
|
||||
</div>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
{/* 步骤预览(violet,与 SkillStepsCard 的"筛选相关技能"步骤同构) */}
|
||||
{showStepPreview && (
|
||||
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
|
||||
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
正在筛选相关技能
|
||||
筛选相关技能
|
||||
{streamingSkills.length > 0 && (
|
||||
<span className="ml-1 text-violet-500/80">
|
||||
· 已识别 {streamingSkills.length} 个
|
||||
<span className="ml-1 text-violet-500/80 font-normal">
|
||||
· 候选 {streamingSkills.length} 个(将按顺序使用)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -117,29 +129,42 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
|
||||
{streamingSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
|
||||
{streamingSkills.map((name, idx) => (
|
||||
<span
|
||||
key={`${name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Zap className="size-2.5 shrink-0" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 已识别的候选技能 badges */}
|
||||
{streamingSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{streamingSkills.map((name, idx) => (
|
||||
<span
|
||||
key={`${name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Zap className="size-2.5 shrink-0" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
{/* reason 实时文本:未闭合也展示(带光标),闭合后光标消失 */}
|
||||
{reason.value && (
|
||||
<div className="text-[11px] text-violet-700/85 dark:text-violet-300/85 italic leading-relaxed whitespace-pre-wrap">
|
||||
<span className="not-italic font-medium text-violet-600 dark:text-violet-400 mr-1">原因:</span>
|
||||
{reason.value}
|
||||
{!reason.closed && (
|
||||
<span className="inline-block w-0.5 h-3 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 还没出现任何 skill / reason 时给一个占位提示 */}
|
||||
{!streamingSkills.length && !reason.value && (
|
||||
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
|
||||
等待模型输出
|
||||
<span className="inline-block w-0.5 h-3 bg-violet-400 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillSearchStep } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
@@ -12,6 +12,7 @@ interface SkillStepsCardProps {
|
||||
|
||||
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
list_skills: ListChecks,
|
||||
match_skills: Search,
|
||||
select_skills: Sparkles,
|
||||
invoke_skill: Zap,
|
||||
skill_result: CheckCircle2,
|
||||
@@ -20,6 +21,7 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
|
||||
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
|
||||
list_skills: '列出可用技能',
|
||||
match_skills: '分析中',
|
||||
select_skills: '筛选相关技能',
|
||||
invoke_skill: '调用技能',
|
||||
skill_result: '检索结果',
|
||||
|
||||
@@ -16,12 +16,13 @@ export interface AIMaterial {
|
||||
|
||||
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
|
||||
// list_skills:列出全部 enabled 技能
|
||||
// match_skills:进入 LLM 流式选择前的过渡提示(让用户立刻看到反馈)
|
||||
// select_skills:LLM 按相关度排序的候选
|
||||
// invoke_skill:开始调用某个技能去检索
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
|
||||
@@ -24,10 +24,7 @@ function PageLayout() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebarEnabled && (
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
<PageSidebarDark />
|
||||
) : (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
@@ -37,7 +34,13 @@ function PageLayout() {
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
!sidebarEnabled
|
||||
? ""
|
||||
: isDark
|
||||
? "pl-[118px]"
|
||||
: collapsed
|
||||
? "pl-16"
|
||||
: "pl-64"
|
||||
}`}
|
||||
>
|
||||
<Routes>
|
||||
@@ -50,6 +53,7 @@ function PageLayout() {
|
||||
pageSidebarHidden={!sidebarEnabled}
|
||||
headerHidden={!sidebarEnabled}
|
||||
compactWorkspaceSidebar={sidebarEnabled}
|
||||
pageSidebarDark={isDark}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -47,18 +47,25 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
: pageSidebarDark
|
||||
// dark page sidebar 视觉右边界是 108px(外框 98 + 内层 translateX 10),
|
||||
// 再 +10px gap 让 workspace sidebar 跟它之间留一条间隔;
|
||||
// 与 PageRoutes.tsx 的 main pl-[118px] 保持一致
|
||||
? "118px"
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
@@ -97,17 +104,20 @@ export default function WorkspaceRoutes({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const layoutProps = {
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden,
|
||||
headerHidden,
|
||||
compactWorkspaceSidebar,
|
||||
pageSidebarDark,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+11
@@ -127,6 +127,13 @@ class SkillPicker:
|
||||
|
||||
fallback = {"selected_skills": [s.name for s in skills[: self._max_select]], "reason": ""}
|
||||
|
||||
# list_skills 之后立刻发一个"分析中"提示步骤,避免 LLM 第一个 chunk 到来前
|
||||
# 用户看到空窗期(某些模型 astream 在头部 buffer 时间较长)
|
||||
self._emit_step({
|
||||
"type": "match_skills",
|
||||
"message": "正在让模型从候选技能中筛选最合适的…",
|
||||
})
|
||||
|
||||
# 流式 LLM 调用:与 intent_parser / writer_outline 同套机制
|
||||
writer = safe_get_stream_writer()
|
||||
parser = ThinkTagParser()
|
||||
@@ -140,6 +147,10 @@ class SkillPicker:
|
||||
"agent_name": "素材收集专家",
|
||||
})
|
||||
|
||||
# 立刻推一个空 chunk,让前端 streaming view 立即挂载(占位 + 闪烁光标),
|
||||
# 即使后续 LLM 首个 chunk 到达较慢,UI 也不再静默
|
||||
_emit_chunk("", False)
|
||||
|
||||
try:
|
||||
model = get_model(self._config, model_name=self._model_name)
|
||||
async for chunk in model.astream([
|
||||
|
||||
Reference in new issue
Block a user