feat(create): 创作首页会话化 - 工作态指示器/计划路线分离/单会话承载分镜/通知中心与空态 hero/历史抽屉分组/行操作 hover 菜单与重命名/任务按钮规范
This commit is contained in:
92
web/src/components/ai/working-indicator.tsx
Normal file
92
web/src/components/ai/working-indicator.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
/**
|
||||
* WorkingDots: diagonal wave dot-matrix "busy" indicator.
|
||||
*
|
||||
* Form absorbed from boardui agent-thinking (wave variant) — no UI assets or
|
||||
* extra dependencies; reduced-motion renders a static frame. The grid is drawn
|
||||
* with `bg-current`, so it inherits whatever text color the caller provides
|
||||
* (canvas theme accents, CSS vars, etc.).
|
||||
*/
|
||||
export function WorkingDots({
|
||||
cols = 3,
|
||||
rows = 3,
|
||||
dotSize = 4,
|
||||
gap = 2,
|
||||
tickMs = 90,
|
||||
minOpacity = 0.14,
|
||||
trail = 0.32,
|
||||
className,
|
||||
}: {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
dotSize?: number;
|
||||
gap?: number;
|
||||
tickMs?: number;
|
||||
minOpacity?: number;
|
||||
trail?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [phase, setPhase] = useState(0);
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return;
|
||||
const timer = window.setInterval(() => setPhase((value) => (value + 1 / 8) % 1), tickMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reducedMotion, tickMs]);
|
||||
const count = cols * rows;
|
||||
return (
|
||||
<span aria-hidden className={`grid shrink-0 ${className ?? ""}`} style={{ gridTemplateColumns: `repeat(${cols}, ${dotSize}px)`, gap }}>
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
// Normalized diagonal position, scaled so the wave front turns
|
||||
// back before reaching the far corner (3/4 for a 3x3 grid,
|
||||
// matching the original canvas indicator pixel-for-pixel).
|
||||
const n = Math.max(cols, rows);
|
||||
const normalized = (col / Math.max(1, cols - 1) + row / Math.max(1, rows - 1)) / 2;
|
||||
const scalar = normalized * (n / (n + 1));
|
||||
const behind = (phase - scalar + 1) % 1;
|
||||
const lit = Math.max(0, 1 - behind / trail) ** 1.5;
|
||||
const opacity = minOpacity + (1 - minOpacity) * lit;
|
||||
return <span key={index} className="rounded-[1px] bg-current" style={{ width: dotSize, height: dotSize, opacity }} />;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkingGlow: soft breathing halo rendered over a "busy" control (send
|
||||
* button, composer capsule). Color accepts a hex value or a CSS variable
|
||||
* (e.g. `var(--creation-text)`); the blurred layer uses color-mix so both
|
||||
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
|
||||
* control in a `relative` container and pass the matching border radius.
|
||||
*/
|
||||
export function WorkingGlow({
|
||||
active,
|
||||
color,
|
||||
radius = 0,
|
||||
className,
|
||||
}: {
|
||||
active: boolean;
|
||||
color: string;
|
||||
radius?: number | string;
|
||||
className?: string;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
if (!active || reducedMotion) return null;
|
||||
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className={className}
|
||||
style={{ position: "absolute", inset: 0, borderRadius: radius, pointerEvents: "none", boxShadow: `0 0 0 1px ${color}` }}
|
||||
animate={{
|
||||
opacity: [0.35, 1, 0.35],
|
||||
boxShadow: [`0 0 0 1px ${color}`, `0 0 16px 1px ${soft}, 0 0 0 1px ${color}`, `0 0 0 1px ${color}`],
|
||||
}}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
@@ -50,37 +51,6 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
|
||||
|
||||
const WORKING_TEXT = "正在推演...";
|
||||
|
||||
// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
|
||||
// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
|
||||
const WORKING_DOTS = 3;
|
||||
const WORKING_DOTS_TICK_MS = 90;
|
||||
const WORKING_DOTS_MIN_OPACITY = 0.14;
|
||||
const WORKING_DOTS_PHASE_STEP = 1 / 8;
|
||||
const WORKING_DOTS_TRAIL = 0.32;
|
||||
|
||||
function AgentWorkingDots() {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [phase, setPhase] = useState(0);
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return;
|
||||
const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [reducedMotion]);
|
||||
return (
|
||||
<span aria-hidden className="grid shrink-0" style={{ gridTemplateColumns: `repeat(${WORKING_DOTS}, 4px)`, gap: 2 }}>
|
||||
{Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
|
||||
const col = index % WORKING_DOTS;
|
||||
const row = Math.floor(index / WORKING_DOTS);
|
||||
// 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
|
||||
const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
|
||||
const behind = (phase - scalar + 1) % 1;
|
||||
const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
|
||||
const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
|
||||
return <span key={index} className="rounded-[1px] bg-current" style={{ width: 4, height: 4, opacity }} />;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentWorkingElapsed() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -304,7 +274,7 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
||||
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="flex items-center gap-2.5" style={{ color: theme.node.muted }}>
|
||||
<AgentWorkingDots />
|
||||
<WorkingDots />
|
||||
<span className="text-sm leading-5">{label}</span>
|
||||
<AgentWorkingElapsed />
|
||||
</div>
|
||||
@@ -417,15 +387,7 @@ export function AgentChatComposer({
|
||||
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
|
||||
}}
|
||||
>
|
||||
{sending && !reducedMotion ? (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-[22px]"
|
||||
style={{ boxShadow: `0 0 0 1px ${theme.accent.primary}` }}
|
||||
animate={{ opacity: [0.3, 1, 0.3], boxShadow: [`0 0 0 1px ${theme.accent.primary}66`, `0 0 18px 2px ${theme.accent.primary}55, 0 0 0 1px ${theme.accent.primary}`, `0 0 0 1px ${theme.accent.primary}66`] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
) : null}
|
||||
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{attachments.map((item) => (
|
||||
|
||||
@@ -1651,8 +1651,23 @@ function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
|
||||
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
|
||||
}
|
||||
|
||||
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
|
||||
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
|
||||
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
|
||||
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
|
||||
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
|
||||
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
|
||||
|
||||
async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
|
||||
return runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice, onDelta });
|
||||
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
|
||||
try {
|
||||
return await submit(toolChoice);
|
||||
} catch (error) {
|
||||
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
|
||||
throw error;
|
||||
}
|
||||
return submit("auto");
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeToolCalls(calls: ResponseToolCall[]) {
|
||||
|
||||
Reference in New Issue
Block a user