样式修改
This commit is contained in:
1 parent
ab6b6876aa
commit
7f1f34e34f
15 files changed
+536
-100
No files matched your search
@@ -105,6 +105,12 @@ const menuItems: MenuItem[] = [
|
||||
icon: List,
|
||||
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 },
|
||||
|
||||
@@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
|
||||
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
result?: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
@@ -495,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(toolCallId, messages);
|
||||
if (toolCallResult) {
|
||||
try {
|
||||
const json = JSON.parse(toolCallResult);
|
||||
step.result = json;
|
||||
} catch {
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
@@ -63,6 +64,15 @@ export function RecentChatList() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
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 } =
|
||||
useParams<{
|
||||
thread_id: string;
|
||||
@@ -161,12 +171,24 @@ export function RecentChatList() {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{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 { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
|
||||
const layers = collectNestedRecords(record);
|
||||
|
||||
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
|
||||
|
||||
let title =
|
||||
pickFromLayers(layers, TITLE_ALIASES) ??
|
||||
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
|
||||
if (!title && indexLabel) {
|
||||
title = `结果 ${indexLabel}`;
|
||||
}
|
||||
let content =
|
||||
pickFromLayers(layers, CONTENT_ALIASES) ??
|
||||
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
|
||||
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
title = pickShortTitleFallback(layers, reserved);
|
||||
}
|
||||
|
||||
if (!title && !content && !url) {
|
||||
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
|
||||
if (!title && !content && !url && !indexLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||||
|
||||
interface GenericMessageGroup<T = string> {
|
||||
type: T;
|
||||
id: string | undefined;
|
||||
@@ -141,21 +143,63 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return (
|
||||
splitInlineReasoningFromAIMessage(message)?.content ??
|
||||
message.content.trim()
|
||||
);
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => (content.type === "text" ? content.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (payload && typeof payload === "object") {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
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;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
|
||||
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) {
|
||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||
const content = extractTextFromMessage(message);
|
||||
if (content) {
|
||||
return content;
|
||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
@@ -31,7 +33,14 @@ const INPUT_CLASS =
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'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 [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
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({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
targetAudience: audience.trim() || '普通读者',
|
||||
targetAudience: hideAdvancedFields
|
||||
? DEFAULT_AUDIENCE
|
||||
: audience.trim() || DEFAULT_AUDIENCE,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount,
|
||||
writingMode: strictMode ? 'strict' : 'loose',
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
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' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-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' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
export function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
export interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -33,7 +33,7 @@ interface SessionItemProps {
|
||||
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 [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
error: '出错了',
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
|
||||
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 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||
{/* 未开始时显示表单 */}
|
||||
{isIdle && (
|
||||
<AIWritingForm onSubmit={startWriting} />
|
||||
<AIWritingForm
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 意图解析:流式 → 结果卡片承接。
|
||||
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
|
||||
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
)}
|
||||
<ReqRow label="目标字数">
|
||||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
</>
|
||||
)}
|
||||
{state.request.modelName && (
|
||||
<ReqRow label="写作模型">
|
||||
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||
|
||||
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
|
||||
interface IntentResultCardProps {
|
||||
intent: ParsedIntent;
|
||||
articleTypes: ArticleType[];
|
||||
hideTargetAudience?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
|
||||
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
|
||||
*/
|
||||
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
|
||||
export function IntentResultCard({
|
||||
intent,
|
||||
articleTypes,
|
||||
hideTargetAudience = false,
|
||||
}: IntentResultCardProps) {
|
||||
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
|
||||
const typeLabel =
|
||||
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
|
||||
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-1.5 text-xs">
|
||||
<Row label="文章类型" value={typeLabel} />
|
||||
<Row label="目标读者" value={intent.targetAudience} />
|
||||
{!hideTargetAudience && (
|
||||
<Row label="目标读者" value={intent.targetAudience} />
|
||||
)}
|
||||
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
|
||||
{intent.refinedIntent && (
|
||||
<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)
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
|
||||
>
|
||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel />
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</div>
|
||||
</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 LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function WorkspaceRoutes({
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage />
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user