样式修改

This commit is contained in:
董宏凯 committed 2026-05-22 21:41:26 +08:00
1 parent ab6b6876aa
commit 7f1f34e34f
15 files changed
+536 -100

No files matched your search

@@ -105,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List, icon: List,
path: "/page/strategy/light-app/notes", path: "/page/strategy/light-app/notes",
}, },
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
], ],
}, },
{ id: "light-app-square", label: "应用广场", icon: Layout }, { id: "light-app-square", label: "应用广场", icon: Layout },
@@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
result?: string; result?: string | Record<string, unknown>;
} }
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -495,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages); const toolCallResult = findToolCallResult(
if (toolCallResult) { toolCallId,
try { messages,
const json = JSON.parse(toolCallResult); tool_call.name,
step.result = json; );
} catch { if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
step.result = JSON.parse(toolCallResult) as Record<
string,
unknown
>;
} catch {
step.result = toolCallResult;
}
} else {
step.result = toolCallResult; step.result = toolCallResult;
} }
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
ChevronDown,
Download, Download,
FileJson, FileJson,
FileText, FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
};
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{ useParams<{
thread_id: string; thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return ( return (
<> <>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"> <SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase"> <SidebarGroupLabel
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
? t.sidebar.recentChats onClick={toggleCollapsed}
: t.sidebar.demoChats} >
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0"> <SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
}`}
>
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => { {visibleThreads.map((thread) => {
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} /> <WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />} {isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent> </SidebarContent>
{!compactChrome && ( {!compactChrome && (
<SidebarFooter> <SidebarFooter>
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record); const layers = collectNestedRecords(record);
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
let title = let title =
pickFromLayers(layers, TITLE_ALIASES) ?? pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 }); pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
if (!title && indexLabel) {
title = `结果 ${indexLabel}`;
}
let content = let content =
pickFromLayers(layers, CONTENT_ALIASES) ?? pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true }); pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved); title = pickShortTitleFallback(layers, reserved);
} }
if (!title && !content && !url) { // 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
if (!title && !content && !url && !indexLabel) {
return null; return null;
} }
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> { interface GenericMessageGroup<T = string> {
type: T; type: T;
id: string | undefined; id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
} }
export function extractTextFromMessage(message: Message) { export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") { const payload = extractToolResultPayload(message);
return ( if (typeof payload === "string") {
splitInlineReasoningFromAIMessage(message)?.content ?? return payload;
message.content.trim()
);
} }
if (Array.isArray(message.content)) { if (payload && typeof payload === "object") {
return message.content try {
.map((content) => (content.type === "text" ? content.text : "")) return JSON.stringify(payload);
.join("\n") } catch {
.trim(); return "";
}
} }
return ""; return "";
} }
/**
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
*/
export function extractToolResultPayload(
message: Message,
): string | Record<string, unknown> | undefined {
const { content } = message;
if (typeof content === "string") {
const trimmed = content.trim();
return trimmed || undefined;
}
if (content && typeof content === "object" && !Array.isArray(content)) {
return content as Record<string, unknown>;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as Record<string, unknown>;
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
continue;
}
if (block.type === "json" && block.json && typeof block.json === "object") {
return block.json as Record<string, unknown>;
}
if (hasSearchResultPayload(block)) {
return block;
}
}
const joined = textParts.join("\n").trim();
return joined || undefined;
}
return undefined;
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g; const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) { function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false; return false;
} }
export function findToolCallResult(toolCallId: string, messages: Message[]) { export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
): string | Record<string, unknown> | undefined {
for (const message of messages) { for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) { if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
const content = extractTextFromMessage(message); continue;
if (content) { }
return content; const payload = extractToolResultPayload(message);
if (payload !== undefined) {
return payload;
}
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) {
for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) {
continue;
}
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined && hasSearchResultPayload(payload)) {
return payload;
} }
} }
} }
return undefined; return undefined;
} }
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps { interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void; onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
} }
interface ModelOption { interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50'; 'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault(); e.preventDefault();
if (!intent.trim()) return; if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6)); const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({ onSubmit({
userIntent: intent.trim(), userIntent: intent.trim(),
articleType, articleType,
targetAudience: audience.trim() || '普通读者', targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount, keywordCount,
writingMode: strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined, modelName: modelName || undefined,
}); });
} }
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div> </div>
</div> </div>
<div className="space-y-1.5"> {!hideAdvancedFields && (
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label> <>
<input <div className="space-y-1.5">
type="number" <label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
min={1} <input
max={10} type="number"
step={1} min={1}
className={INPUT_CLASS} max={10}
placeholder="6" step={1}
value={keywordCountInput} className={INPUT_CLASS}
onChange={(e) => setKeywordCountInput(e.target.value)} placeholder="6"
onBlur={handleKeywordCountBlur} value={keywordCountInput}
disabled={disabled} onChange={(e) => setKeywordCountInput(e.target.value)}
/> onBlur={handleKeywordCountBlur}
<p className="text-[11px] text-muted-foreground/70"> disabled={disabled}
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 />
</p> <p className="text-[11px] text-muted-foreground/70">
</div> 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input <input
type="checkbox" type="checkbox"
id="strict-mode" id="strict-mode"
checked={strictMode} checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)} onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled} disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer" className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/> />
<label htmlFor="strict-mode" className="cursor-pointer select-none"> <label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span> <span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed"> <p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p> </p>
</label> </label>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label> <label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…" placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience} value={audience}
onChange={(e) => setAudience(e.target.value)} onChange={(e) => setAudience(e.target.value)}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
</>
)}
{models.length > 0 && ( {models.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions'; import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = { export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' }, in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' }, draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' }, review_ready: { label: '审核', color: 'text-purple-500' },
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
error: { label: '错误', color: 'text-destructive' }, error: { label: '错误', color: 'text-destructive' },
}; };
function formatRelative(isoStr: string): string { export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime(); const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000); const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚'; if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} }
interface SessionItemProps { export interface SessionItemProps {
session: AIWritingSessionSummary; session: AIWritingSessionSummary;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void; onRename: (title: string) => void;
} }
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) { export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title); const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了', error: '出错了',
}; };
export function AIWritingPanel() { export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext(); const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset(); reset();
}, [reset]); }, [reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
useEffect(() => {
handleSelectHistoryRef.current = handleSelectHistory;
}, [handleSelectHistory]);
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
useEffect(() => {
const raw = sessionStorage.getItem('ai-writing:pending-session');
if (!raw) return;
sessionStorage.removeItem('ai-writing:pending-session');
try {
const session = JSON.parse(raw) as AIWritingSessionSummary;
handleSelectHistoryRef.current(session);
} catch { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
useEffect(() => {
function onOpenSession(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) handleSelectHistoryRef.current(session);
}
window.addEventListener('ai-writing:open-session', onOpenSession);
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ──────────────────────────────── // ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。 // ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。 // 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto"> <div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */} {/* 未开始时显示表单 */}
{isIdle && ( {isIdle && (
<AIWritingForm onSubmit={startWriting} /> <AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)} )}
{/* 意图解析:流式 → 结果卡片承接。 {/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} /> <IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)} )}
{state.parsedIntent && !state.intentStreamingText && ( {state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} /> <IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)} )}
{/* 运行中或完成后显示时间线 */} {/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType} {articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span> </span>
</ReqRow> </ReqRow>
<ReqRow label="目标读者"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.targetAudience}</span> <ReqRow label="目标读者">
</ReqRow> <span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数"> <ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span> <span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow> </ReqRow>
<ReqRow label="检索关键词数量"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.keywordCount} 个</span> <>
</ReqRow> <ReqRow label="检索关键词数量">
<ReqRow label="写作模式"> <span className="text-muted-foreground">{state.request.keywordCount} 个</span>
<span className="text-muted-foreground"> </ReqRow>
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'} <ReqRow label="写作模式">
</span> <span className="text-muted-foreground">
</ReqRow> {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && ( {state.request.modelName && (
<ReqRow label="写作模型"> <ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span> <span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps { interface IntentResultCardProps {
intent: ParsedIntent; intent: ParsedIntent;
articleTypes: ArticleType[]; articleTypes: ArticleType[];
hideTargetAudience?: boolean;
} }
/** /**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项: * 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。 * 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/ */
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) { export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道") // article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel = const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType; articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div> </div>
<div className="px-3 py-2.5 space-y-1.5 text-xs"> <div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} /> <Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} /> {!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} /> <Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && ( {intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline /> <Row label="精炼意图" value={intent.refinedIntent} multiline />
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() { export default function AIWritingPage({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
return ( return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
> >
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */} {/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel /> <AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BookOpen, Plus, Send } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (!question.trim()) return;
setQuestion("");
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
isDark ? "bg-slate-950" : "bg-white",
)}
>
{/* 背景光晕 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
{/* 主内容 */}
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
{/* 笔记图标 */}
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
</div>
{/* 笔记标题 */}
<h1
className={cn(
"text-xl font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-900",
)}
>
笔记01
</h1>
{/* 新增来源按钮 */}
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
新增来源
</button>
{/* 问答输入框 */}
<div
className={cn(
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
isDark
? "bg-slate-800 ring-1 ring-white/10"
: "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<Plus
className={cn(
"h-4 w-4 shrink-0",
isDark ? "text-slate-500" : "text-slate-400",
)}
/>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder="开始问答"
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none",
isDark
? "text-slate-200 placeholder:text-slate-500"
: "text-slate-700 placeholder:text-slate-400",
)}
/>
{question.trim() && (
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */} {/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */} {/* 对话 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="chats" element={<ChatsPage />} />
+1 -1
View File
@@ -182,7 +182,7 @@ export default function WorkspaceRoutes({
path="ai-writing" path="ai-writing"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AIWritingPage /> <AIWritingPage hideAdvancedFields />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />