From ab9d2422f98d95caa0d6f3d07d63543e79ad9f7d Mon Sep 17 00:00:00 2001 From: maomaochong-ai Date: Sun, 6 Sep 2026 20:29:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AI=20=E6=B6=88=E6=81=AF=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E5=A4=96=E5=A3=B3=E4=B8=8E=E6=80=9D=E7=BB=B4?= =?UTF-8?q?=E5=8D=A1=E6=B5=81=E5=BC=8F=E8=AE=A1=E6=97=B6=20-=20shiki=20?= =?UTF-8?q?=E5=8F=8C=E4=B8=BB=E9=A2=98=E9=AB=98=E4=BA=AE/=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=85=89=E6=A0=87/=E5=A4=8D=E5=88=B6=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=9D=A1,=20250ms=20=E8=8A=82=E6=B5=81=20mono=20?= =?UTF-8?q?=E7=A7=92=E6=95=B0=20(DCE=20=E5=B7=A5=E5=85=B7=E5=8D=A1?= =?UTF-8?q?=E6=B8=85=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ai/ai-message-code-block.tsx | 171 ++++++++++++++++++ web/src/components/ai/ai-message-markdown.tsx | 12 +- .../components/ai/generation-tool-card.tsx | 2 +- web/src/components/ai/message-reasoning.tsx | 11 +- web/src/components/ai/working-indicator.tsx | 12 +- web/src/styles/globals.css | 68 ++++++- 6 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 web/src/components/ai/ai-message-code-block.tsx diff --git a/web/src/components/ai/ai-message-code-block.tsx b/web/src/components/ai/ai-message-code-block.tsx new file mode 100644 index 000000000..bfc7745c3 --- /dev/null +++ b/web/src/components/ai/ai-message-code-block.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Check, Copy } from "lucide-react"; + +type AiMessageCodeBlockProps = { + children?: ReactNode; + className?: string; + isStreaming?: boolean; +}; + +type ShikiHighlighter = Awaited>; + +let highlighterPromise: Promise | null = null; + +const SHIKI_LANGS = [ + "javascript", + "typescript", + "jsx", + "tsx", + "json", + "html", + "css", + "scss", + "bash", + "shellscript", + "python", + "sql", + "markdown", + "yaml", + "diff", + "xml", + "java", + "go", + "rust", + "c", + "cpp", + "csharp", + "ruby", + "php", + "kotlin", + "swift", + "docker", + "text", +] as const; + +/** 用户常见语言写法 → shiki 语言名;未知语言回退纯文本(无高亮但外壳不变)。 */ +const LANGUAGE_ALIASES: Record = { + sh: "shellscript", + shell: "shellscript", + zsh: "shellscript", + js: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + mts: "typescript", + py: "python", + yml: "yaml", + md: "markdown", + mdx: "markdown", + rb: "ruby", + rs: "rust", + go: "go", + "c++": "cpp", + "c#": "csharp", + cs: "csharp", + kt: "kotlin", + dockerfile: "docker", + text: "text", +}; + +function loadHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = import("shiki").then(({ createHighlighter }) => + createHighlighter({ + langs: [...SHIKI_LANGS], + themes: ["github-light", "github-dark"], + }), + ); + } + return highlighterPromise; +} + +function collectText(node: ReactNode): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(collectText).join(""); + if (typeof node === "object" && "props" in node) { + return collectText((node.props as { children?: ReactNode }).children); + } + return ""; +} + +function detectLanguage(children: ReactNode, ownClassName?: string): string { + const pattern = /language-([^\s]+)/; + const own = typeof ownClassName === "string" ? ownClassName.match(pattern) : null; + if (own) return own[1]; + const first = Array.isArray(children) ? children[0] : children; + if (first && typeof first === "object" && "props" in first) { + const className = (first.props as { className?: unknown }).className; + const match = typeof className === "string" ? className.match(pattern) : null; + if (match) return match[1]; + } + return ""; +} + +/** + * 自研代码块:语言标签 + 复制按钮 + 统一圆角/边框/底色; + * 消息结束后用 shiki 对代码上色(懒加载,github-light/dark 双主题 CSS 变量, + * 由 .dark 类切换),流式期间保持单色避免逐帧闪烁。 + */ +export function AiMessageCodeBlock({ children, className, isStreaming = false, ...props }: AiMessageCodeBlockProps) { + const [copied, setCopied] = useState(false); + const [highlightHtml, setHighlightHtml] = useState(null); + const timerRef = useRef(undefined); + const codeText = collectText(children); + const language = detectLanguage(children, className).toLowerCase(); + + useEffect(() => { + if (isStreaming || !codeText.trim()) { + setHighlightHtml(null); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + void loadHighlighter() + .then(async (highlighter) => { + if (cancelled) return; + const aliased = LANGUAGE_ALIASES[language]; + const lang = aliased ?? ((SHIKI_LANGS as readonly string[]).includes(language) ? language : "text"); + const html = await highlighter.codeToHtml(codeText.trimEnd(), { + lang, + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + cssVariablePrefix: "--shiki-", + }); + if (!cancelled) setHighlightHtml(html); + }) + .catch(() => {}); + }, 120); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [codeText, language, isStreaming]); + + const handleCopy = () => { + const payload = codeText.trimEnd(); + void navigator.clipboard?.writeText(payload).catch(() => {}); + setCopied(true); + window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setCopied(false), 1600); + }; + + return ( +
+
+ {language || "code"} + +
+ {highlightHtml ? ( +
+ ) : ( +
+                    {children}
+                
+ )} +
+ ); +} diff --git a/web/src/components/ai/ai-message-markdown.tsx b/web/src/components/ai/ai-message-markdown.tsx index 877e4d138..a25d6b682 100644 --- a/web/src/components/ai/ai-message-markdown.tsx +++ b/web/src/components/ai/ai-message-markdown.tsx @@ -1,4 +1,5 @@ import { Streamdown, type Components } from "streamdown"; +import { AiMessageCodeBlock } from "./ai-message-code-block"; import "streamdown/styles.css"; @@ -8,7 +9,8 @@ type AIMessageMarkdownProps = { className?: string; }; -const components: Components = { +function buildComponents(isStreaming: boolean): Components { + return { h1: ({ children, ...props }) =>

{children}

, h2: ({ children, ...props }) =>

{children}

, h3: ({ children, ...props }) =>

{children}

, @@ -18,7 +20,7 @@ const components: Components = { ol: ({ children, ...props }) =>
    {children}
, li: ({ children, ...props }) =>
  • {children}
  • , blockquote: ({ children, ...props }) =>
    {children}
    , - pre: ({ children, ...props }) =>
    {children}
    , + pre: ({ children, ...props }) => {children}, code: ({ children, className, ...props }) => {children}, a: ({ children, ...props }) => {children}, hr: (props) =>
    , @@ -26,7 +28,9 @@ const components: Components = { th: ({ children, ...props }) => {children}, td: ({ children, ...props }) => {children}, input: ({ ...props }) => , -}; + }; +} + export function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) { if (!children.trim()) return null; @@ -40,7 +44,7 @@ export function AIMessageMarkdown({ children, isStreaming = false, className = " parseIncompleteMarkdown skipHtml lineNumbers={false} - components={components} + components={buildComponents(isStreaming)} > {children} diff --git a/web/src/components/ai/generation-tool-card.tsx b/web/src/components/ai/generation-tool-card.tsx index 4e4dbd4ec..188e04702 100644 --- a/web/src/components/ai/generation-tool-card.tsx +++ b/web/src/components/ai/generation-tool-card.tsx @@ -3,7 +3,7 @@ import { ChevronDown } from "lucide-react"; export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled"; -export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) { +export function GenerationToolCard({ status, heading, children }: { status: GenerationToolStatus; heading: ReactNode; children: ReactNode }) { const [open, setOpen] = useState(true); useEffect(() => { diff --git a/web/src/components/ai/message-reasoning.tsx b/web/src/components/ai/message-reasoning.tsx index 3f74c923e..33fc5234c 100644 --- a/web/src/components/ai/message-reasoning.tsx +++ b/web/src/components/ai/message-reasoning.tsx @@ -3,6 +3,15 @@ import { ChevronDown, Sparkles } from "lucide-react"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; +function LiveReasoningCount() { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + useEffect(() => { + const startedAt = Date.now(); + const timer = window.setInterval(() => setElapsedSeconds((Date.now() - startedAt) / 1000), 250); + return () => window.clearInterval(timer); + }, []); + return {elapsedSeconds.toFixed(1)}s; +} export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) { const [open, setOpen] = useState(isStreaming); const [durationSeconds, setDurationSeconds] = useState(); @@ -33,7 +42,7 @@ export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string return
    {open ?
    {reasoning}
    : null} diff --git a/web/src/components/ai/working-indicator.tsx b/web/src/components/ai/working-indicator.tsx index fc624ce0b..5ce078972 100644 --- a/web/src/components/ai/working-indicator.tsx +++ b/web/src/components/ai/working-indicator.tsx @@ -63,17 +63,7 @@ export function WorkingDots({ * 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; -}) { +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)`; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 745d66bf9..0e4ce5c5e 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -15264,21 +15264,79 @@ html:not(.dark) .creation-history-drawer-root { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.88em; } -.ai-message-markdown-pre { +.ai-message-codeblock { margin: 0.8em 0; - padding: 0.8em 0.9em; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--foreground) 5%, var(--background)); +} +.ai-message-codeblock-header { + display: flex; + height: 34px; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: 0 var(--space-2) 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); +} +.ai-message-codeblock-lang { + overflow: hidden; + color: color-mix(in srgb, currentColor 56%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--fs-micro); + font-weight: 600; + letter-spacing: 0.05em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} +.ai-message-codeblock-copy { + display: inline-flex; + height: 24px; + flex: 0 0 auto; + align-items: center; + gap: 5px; + border: 0; + border-radius: 6px; + background: transparent; + padding: 0 var(--space-2); + color: color-mix(in srgb, currentColor 62%, transparent); + font: inherit; + font-size: var(--fs-micro); + transition: background-color 150ms ease, color 150ms ease; +} +.ai-message-codeblock-copy svg { width: 13px; height: 13px; } +.ai-message-codeblock-copy:hover { background: color-mix(in srgb, currentColor 10%, transparent); color: inherit; } +.ai-message-codeblock-copy:focus-visible { outline: 2px solid color-mix(in srgb, var(--primary) 60%, transparent); outline-offset: -1px; } +.ai-message-codeblock-pre { + margin: 0; + padding: 10px 12px; overflow-x: auto; - border-radius: var(--r-sm); - background: color-mix(in srgb, var(--foreground) 7%, var(--background)); + background: transparent; color: color-mix(in srgb, var(--foreground) 86%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.84em; line-height: 1.55; + tab-size: 2; } -.ai-message-markdown-pre .ai-message-markdown-code { +.ai-message-codeblock-pre .ai-message-markdown-code { padding: 0; background: transparent; + color: inherit; font-size: inherit; } +@media (prefers-reduced-motion: reduce) { + .ai-message-codeblock-copy { transition: none; } +} + +/* --- ai-message 代码块语法高亮(shiki 双主题 CSS 变量:--shiki-light/--shiki-dark)--- */ +.ai-message-codeblock-pre .shiki { margin: 0; padding: 0; background: transparent !important; } +.ai-message-codeblock-pre .shiki code { font-family: inherit; font-size: inherit; color: inherit; } +html:not(.dark) .ai-message-codeblock-pre .shiki, +html:not(.dark) .ai-message-codeblock-pre .shiki span { color: var(--shiki-light) !important; } +html.dark .ai-message-codeblock-pre .shiki, +html.dark .ai-message-codeblock-pre .shiki span { color: var(--shiki-dark) !important; } .ai-message-markdown-link { color: var(--primary); text-decoration: underline;