Commit 91f42aa0 authored by AI-甘富林's avatar AI-甘富林

feat(ui): refresh chat and settings interactions

parent 5e19ad5f
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import brandIcon from "./assets/brand-icon.png"; import brandIcon from "./assets/brand-icon.png";
import type { ReactNode, ChangeEvent, KeyboardEvent as ReactKeyboardEvent } from "react"; import type { ReactNode, ChangeEvent, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
import type { import type {
AppConfig, AppConfig,
ChatAttachment, ChatAttachment,
...@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info"; ...@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info";
type MessageStreamState = "streaming" | "error"; type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"; type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
type TraceTone = "info" | "error" | "success"; type TraceTone = "info" | "error" | "success";
type MessageReaction = "up" | "down";
type MessagesBySession = Record<string, UiChatMessage[]>; type MessagesBySession = Record<string, UiChatMessage[]>;
type UiChatMessage = ChatMessage & { type UiChatMessage = ChatMessage & {
...@@ -564,7 +565,7 @@ const ui = { ...@@ -564,7 +565,7 @@ const ui = {
expertReady: "\u5df2\u5c31\u7eea", expertReady: "\u5df2\u5c31\u7eea",
activeExpert: "\u5f53\u524d\u4e13\u5bb6", activeExpert: "\u5f53\u524d\u4e13\u5bb6",
starterQuestionsHint: "\u70b9\u51fb\u95ee\u9898\u540e\u4f1a\u5148\u586b\u5165\u8f93\u5165\u6846\uff0c\u4f60\u53ef\u4ee5\u7ee7\u7eed\u8865\u5145\u540e\u518d\u53d1\u9001\u3002", starterQuestionsHint: "\u70b9\u51fb\u95ee\u9898\u540e\u4f1a\u5148\u586b\u5165\u8f93\u5165\u6846\uff0c\u4f60\u53ef\u4ee5\u7ee7\u7eed\u8865\u5145\u540e\u518d\u53d1\u9001\u3002",
taskPlaceholder: "\u8f93\u5165\u4f60\u7684\u9700\u6c42\uff0c\u53ef\u7528 Ctrl+Enter \u53d1\u9001\u3002", taskPlaceholder: "\u8f93\u5165\u4f60\u7684\u9700\u6c42\uff0cEnter \u53d1\u9001\uff0cShift+Enter \u6362\u884c\u3002",
taskDisabledPlaceholder: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u5f00\u59cb\u5bf9\u8bdd\u3002", taskDisabledPlaceholder: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u5f00\u59cb\u5bf9\u8bdd\u3002",
send: "\u53d1\u9001", send: "\u53d1\u9001",
sending: "\u53d1\u9001\u4e2d...", sending: "\u53d1\u9001\u4e2d...",
...@@ -615,6 +616,9 @@ const ui = { ...@@ -615,6 +616,9 @@ const ui = {
suggestionDismiss: "暂不切换", suggestionDismiss: "暂不切换",
suggestionSwitchAction: "切换并继续", suggestionSwitchAction: "切换并继续",
saveSuccessPending: "\u5458\u5de5\u5bc6\u94a5\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002", saveSuccessPending: "\u5458\u5de5\u5bc6\u94a5\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
saveSuccessApplied: "\u6a21\u578b\u914d\u7f6e\u5df2\u4fdd\u5b58\uff0c\u65b0\u7684\u914d\u7f6e\u5c06\u5728\u540e\u7eed\u6267\u884c\u4e2d\u751f\u6548\u3002",
copy: "\u590d\u5236",
copied: "\u5df2\u590d\u5236",
bindFirst: "\u8bf7\u5148\u7ed1\u5b9a", bindFirst: "\u8bf7\u5148\u7ed1\u5b9a",
bindFirstError: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u518d\u53d1\u9001\u6d88\u606f\u3002", bindFirstError: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u518d\u53d1\u9001\u6d88\u606f\u3002",
startingHint: "\u6b63\u5728\u51c6\u5907\u8fd0\u884c\u73af\u5883\uff0c\u8bf7\u7a0d\u5019\u3002", startingHint: "\u6b63\u5728\u51c6\u5907\u8fd0\u884c\u73af\u5883\uff0c\u8bf7\u7a0d\u5019\u3002",
...@@ -1291,15 +1295,338 @@ type ExpertVisualKey = ...@@ -1291,15 +1295,338 @@ type ExpertVisualKey =
| "geo" | "geo"
| "leads"; | "leads";
function SendArrowIcon() { function SendRocketIcon() {
return ( return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <span className="send-rocket-pair" aria-hidden="true">
<path d="M19 12H7" /> {[0, 1].map((index) => (
<path d="m12 7-5 5 5 5" /> <svg key={index} viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M12.9 4.2c2.4 1.2 4.1 3.3 4.9 6.1-2.4.8-4.6 2.4-6.3 4.5l-1.9 2.5-1.9-.5.5-1.9 2.5-1.9c2.1-1.7 3.7-3.9 4.5-6.3-1.2-.3-2.4-.1-3.4.8l-4.2 4.2-2.6.7.7-2.6 4.2-4.2c1.6-1.6 3.9-2.1 6-1.4Z" fill="currentColor" />
<path d="M8.2 15.8 6 18l-1.6-1.6 2.2-2.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.3 5.6 18.4 9.7" stroke="#ffffff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.72" />
</svg>
))}
</span>
);
}
function AttachmentIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M8.5 12.7 14 7.2a3.5 3.5 0 1 1 5 5L11 20.1a5.5 5.5 0 0 1-7.8-7.8l8.2-8.2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<rect x="9" y="9" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M6 15V7a2 2 0 0 1 2-2h8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M5.5 12.5 10 17l8.5-9" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ArrowUpIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M12 18V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="m7 11 5-5 5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function RefreshIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 11a7 7 0 1 0 1.6 4.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
); );
} }
function ThumbIcon({ direction }: { direction: MessageReaction }) {
return direction === "up" ? (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M7 11v9H4v-9h3Zm3 9h6.4c1.2 0 2.2-.8 2.5-1.9l1.3-4.6c.4-1.5-.7-3-2.3-3H14V5.8c0-1-.8-1.8-1.8-1.8a1 1 0 0 0-.9.5L8.1 11.1a2 2 0 0 0-.3 1V20h2.2Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M7 4v9H4V4h3Zm3 0h6.4c1.2 0 2.2.8 2.5 1.9l1.3 4.6c.4 1.5-.7 3-2.3 3H14v4.7c0 1-.8 1.8-1.8 1.8a1 1 0 0 1-.9-.5l-3.2-6.6a2 2 0 0 1-.3-1V4h2.2Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function MoreIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="6.5" cy="12" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="17.5" cy="12" r="1.5" />
</svg>
);
}
function formatCodeLanguageLabel(language: string): string {
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "Text";
}
const aliases: Record<string, string> = {
js: "JavaScript",
ts: "TypeScript",
jsx: "React JSX",
tsx: "React TSX",
py: "Python",
sh: "Shell",
yml: "YAML",
md: "Markdown"
};
return aliases[normalized] ?? normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function renderMarkdownInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
let tokenIndex = 0;
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) {
nodes.push(text.slice(cursor, match.index));
}
const key = `${keyPrefix}-${tokenIndex}`;
if (match[1]) {
nodes.push(<code key={key} className="markdown-inline-code">{match[1]}</code>);
} else if (match[2] && match[3]) {
nodes.push(
<a key={key} href={match[3]} target="_blank" rel="noreferrer" className="markdown-link">
{renderMarkdownInline(match[2], `${key}-link`)}
</a>
);
} else if (match[4] || match[5]) {
nodes.push(<strong key={key}>{renderMarkdownInline(match[4] || match[5] || "", `${key}-strong`)}</strong>);
} else if (match[6] || match[7]) {
nodes.push(<em key={key}>{renderMarkdownInline(match[6] || match[7] || "", `${key}-em`)}</em>);
}
cursor = pattern.lastIndex;
tokenIndex += 1;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return nodes;
}
function renderParagraphLines(lines: string[], keyPrefix: string): ReactNode[] {
return lines.flatMap((line, lineIndex) => [
lineIndex > 0 ? <br key={`${keyPrefix}-br-${lineIndex}`} /> : null,
...renderMarkdownInline(line, `${keyPrefix}-line-${lineIndex}`)
]);
}
function renderMarkdownHeading(level: number, key: string, children: ReactNode[]) {
switch (level) {
case 1:
return <h1 key={key}>{children}</h1>;
case 2:
return <h2 key={key}>{children}</h2>;
case 3:
return <h3 key={key}>{children}</h3>;
case 4:
return <h4 key={key}>{children}</h4>;
case 5:
return <h5 key={key}>{children}</h5>;
default:
return <h6 key={key}>{children}</h6>;
}
}
function renderMarkdownBlocks(
text: string,
keyPrefix: string
): ReactNode[] {
const normalized = text.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
const blocks: ReactNode[] = [];
let index = 0;
const isBlockBoundary = (line: string) => {
const trimmed = line.trim();
return !trimmed
|| /^#{1,6}\s+/.test(trimmed)
|| /^>\s?/.test(trimmed)
|| /^[-*_]{3,}\s*$/.test(trimmed)
|| /^\s*[-*]\s+/.test(trimmed)
|| /^\s*\d+\.\s+/.test(trimmed);
};
while (index < lines.length) {
const current = lines[index];
const trimmed = current.trim();
if (!trimmed) {
index += 1;
continue;
}
const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (headingMatch) {
const level = Math.min(headingMatch[1].length, 6);
blocks.push(
renderMarkdownHeading(
level,
`${keyPrefix}-heading-${index}`,
renderMarkdownInline(headingMatch[2], `${keyPrefix}-heading-${index}`)
)
);
index += 1;
continue;
}
if (/^[-*_]{3,}\s*$/.test(trimmed)) {
blocks.push(<hr key={`${keyPrefix}-rule-${index}`} className="markdown-rule" />);
index += 1;
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
quoteLines.push(lines[index].replace(/^\s*>\s?/, ""));
index += 1;
}
blocks.push(
<blockquote key={`${keyPrefix}-quote-${index}`} className="markdown-quote">
<p>{renderParagraphLines(quoteLines, `${keyPrefix}-quote-${index}`)}</p>
</blockquote>
);
continue;
}
if (/^\s*[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index].trim())) {
items.push(lines[index].replace(/^\s*[-*]\s+/, ""));
index += 1;
}
blocks.push(
<ul key={`${keyPrefix}-ul-${index}`} className="markdown-list markdown-list-unordered">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ul-${index}-${itemIndex}`}>{renderMarkdownInline(item, `${keyPrefix}-ul-item-${itemIndex}`)}</li>
))}
</ul>
);
continue;
}
if (/^\s*\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].replace(/^\s*\d+\.\s+/, ""));
index += 1;
}
blocks.push(
<ol key={`${keyPrefix}-ol-${index}`} className="markdown-list markdown-list-ordered">
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-ol-${index}-${itemIndex}`}>{renderMarkdownInline(item, `${keyPrefix}-ol-item-${itemIndex}`)}</li>
))}
</ol>
);
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && !isBlockBoundary(lines[index])) {
paragraphLines.push(lines[index]);
index += 1;
}
blocks.push(
<p key={`${keyPrefix}-p-${index}`} className="markdown-paragraph">
{renderParagraphLines(paragraphLines, `${keyPrefix}-p-${index}`)}
</p>
);
}
return blocks;
}
function renderMarkdownContent(
content: string,
options: {
messageId: string;
copiedToken: string;
onCopy: (token: string, text: string) => void | Promise<void>;
}
): ReactNode[] {
const normalized = content.replace(/\r\n?/g, "\n");
const blocks: ReactNode[] = [];
const fencePattern = /```([^\n`]*)\n([\s\S]*?)```/g;
let cursor = 0;
let blockIndex = 0;
let match: RegExpExecArray | null;
while ((match = fencePattern.exec(normalized)) !== null) {
const proseBeforeFence = normalized.slice(cursor, match.index);
if (proseBeforeFence.trim()) {
blocks.push(...renderMarkdownBlocks(proseBeforeFence, `${options.messageId}-md-${blockIndex}`));
blockIndex += 1;
}
const language = (match[1] || "").trim().split(/\s+/)[0] || "";
const code = match[2].replace(/\n$/, "");
const copyToken = `message:${options.messageId}:code:${blockIndex}`;
blocks.push(
<div key={`${options.messageId}-code-${blockIndex}`} className="markdown-code-block">
<div className="markdown-code-toolbar">
<span>{formatCodeLanguageLabel(language)}</span>
<button
type="button"
className={"markdown-code-copy" + (options.copiedToken === copyToken ? " copied" : "")}
aria-label="复制代码"
title="复制代码"
onClick={() => void options.onCopy(copyToken, code)}
>
{options.copiedToken === copyToken ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
<pre className="markdown-code-pre">
<code>{code}</code>
</pre>
</div>
);
cursor = match.index + match[0].length;
blockIndex += 1;
}
const trailingProse = normalized.slice(cursor);
if (trailingProse.trim()) {
blocks.push(...renderMarkdownBlocks(trailingProse, `${options.messageId}-md-${blockIndex}`));
}
return blocks.length > 0
? blocks
: [
<p key={`${options.messageId}-md-empty`} className="markdown-paragraph">
{renderParagraphLines([normalized], `${options.messageId}-md-empty`)}
</p>
];
}
const SIDEBAR_EXPERT_ENTRY_ORDER = new Map<string, number>([ const SIDEBAR_EXPERT_ENTRY_ORDER = new Map<string, number>([
["xhs", 20], ["xhs", 20],
["douyin", 21], ["douyin", 21],
...@@ -1670,16 +1997,21 @@ export default function App() { ...@@ -1670,16 +1997,21 @@ export default function App() {
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState(""); const [infoText, setInfoText] = useState("");
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({}); const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({});
const [messageReactions, setMessageReactions] = useState<Record<string, MessageReaction | undefined>>({});
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({}); const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [sessionActionMenuId, setSessionActionMenuId] = useState("");
const [skillMenuOpen, setSkillMenuOpen] = useState(false); const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const [isComposerDragOver, setIsComposerDragOver] = useState(false);
const [copiedToken, setCopiedToken] = useState("");
const activeStreamRef = useRef<ActiveStreamState | null>(null); const activeStreamRef = useRef<ActiveStreamState | null>(null);
const skillMenuRef = useRef<HTMLDivElement | null>(null); const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const copiedTokenResetRef = useRef<number | null>(null);
const composerDragDepthRef = useRef(0);
const startupWarmupRequestedRef = useRef(false); const startupWarmupRequestedRef = useRef(false);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null); const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null);
const minimizeWindow = () => void desktopApi.window.minimize(); const minimizeWindow = () => void desktopApi.window.minimize();
const maximizeWindow = () => void desktopApi.window.maximize(); const maximizeWindow = () => void desktopApi.window.maximize();
const closeWindow = () => void desktopApi.window.close();
const catalogSkills = workspace?.skills ?? []; const catalogSkills = workspace?.skills ?? [];
const readySkills = useMemo(() => catalogSkills.filter((skill) => skill.ready), [catalogSkills]); const readySkills = useMemo(() => catalogSkills.filter((skill) => skill.ready), [catalogSkills]);
const effectiveSkills = useMemo(() => (readySkills.length ? [DEFAULT_SKILL, ...readySkills] : [DEFAULT_SKILL]), [readySkills]); const effectiveSkills = useMemo(() => (readySkills.length ? [DEFAULT_SKILL, ...readySkills] : [DEFAULT_SKILL]), [readySkills]);
...@@ -1771,6 +2103,16 @@ export default function App() { ...@@ -1771,6 +2103,16 @@ export default function App() {
const showStartupOverlay = startupStateActive && !hasVisibleConversation; const showStartupOverlay = startupStateActive && !hasVisibleConversation;
const sending = sendPhase !== "idle"; const sending = sendPhase !== "idle";
const canSend = isBound && hasConversationProject && (prompt.trim().length > 0 || Boolean(composerAttachment)) && !sending && !saving; const canSend = isBound && hasConversationProject && (prompt.trim().length > 0 || Boolean(composerAttachment)) && !sending && !saving;
const hasPendingLobsterKey = lobsterKeyDraft.trim().length > 0;
const hasPendingModelKeys = Boolean(
imageModelApiKeyDraft.trim()
|| videoModelApiKeyDraft.trim()
|| copywritingModelApiKeyDraft.trim()
|| digitalHumanVolcAccessKeyDraft.trim()
|| digitalHumanVolcSecretKeyDraft.trim()
|| digitalHumanQiniuAccessKeyDraft.trim()
|| digitalHumanQiniuSecretKeyDraft.trim()
);
const sendButtonLabel = sendPhase === "preparing" const sendButtonLabel = sendPhase === "preparing"
? ui.preparing ? ui.preparing
: sendPhase === "streaming" || sendPhase === "finalizing" : sendPhase === "streaming" || sendPhase === "finalizing"
...@@ -1785,7 +2127,7 @@ export default function App() { ...@@ -1785,7 +2127,7 @@ export default function App() {
const pluginGroups = useMemo(() => groupPluginsByStatus(workspace?.plugins), [workspace?.plugins]); const pluginGroups = useMemo(() => groupPluginsByStatus(workspace?.plugins), [workspace?.plugins]);
useEffect(() => { useEffect(() => {
if (viewMode !== "experts") { if (viewMode !== "chat" && viewMode !== "experts") {
clearComposerAttachment(); clearComposerAttachment();
} }
}, [viewMode]); }, [viewMode]);
...@@ -1829,6 +2171,12 @@ export default function App() { ...@@ -1829,6 +2171,12 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [infoText]); }, [infoText]);
useEffect(() => () => {
if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current);
}
}, []);
function updateSessionMessages(sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) { function updateSessionMessages(sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) {
if (!sessionId) { if (!sessionId) {
return; return;
...@@ -3060,7 +3408,7 @@ export default function App() { ...@@ -3060,7 +3408,7 @@ export default function App() {
setDigitalHumanVolcSecretKeyDraft(""); setDigitalHumanVolcSecretKeyDraft("");
setDigitalHumanQiniuAccessKeyDraft(""); setDigitalHumanQiniuAccessKeyDraft("");
setDigitalHumanQiniuSecretKeyDraft(""); setDigitalHumanQiniuSecretKeyDraft("");
setInfoText(trimmedLobsterKey ? ui.saveSuccessPending : "模型配置已保存。"); setInfoText(trimmedLobsterKey ? ui.saveSuccessPending : ui.saveSuccessApplied);
void refresh(false); void refresh(false);
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
...@@ -3351,16 +3699,37 @@ export default function App() { ...@@ -3351,16 +3699,37 @@ export default function App() {
} }
async function handleComposerKeyDown(event: ReactKeyboardEvent<HTMLTextAreaElement>) { async function handleComposerKeyDown(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter" || event.shiftKey || event.altKey) { if (event.nativeEvent.isComposing || event.key !== "Enter" || event.shiftKey || event.altKey) {
return; return;
} }
if (!(event.ctrlKey || event.metaKey)) { event.preventDefault();
await sendPrompt();
}
async function closeWindow() {
await desktopApi.window.close();
}
async function handleCopyText(token: string, text: string) {
const resolved = text.trim();
if (!resolved) {
return; return;
} }
event.preventDefault(); try {
await sendPrompt(); await navigator.clipboard.writeText(resolved);
setCopiedToken(token);
if (copiedTokenResetRef.current !== null) {
window.clearTimeout(copiedTokenResetRef.current);
}
copiedTokenResetRef.current = window.setTimeout(() => {
setCopiedToken("");
copiedTokenResetRef.current = null;
}, 2000);
} catch (error) {
setErrorText(err(error));
}
} }
async function continuePendingHomePromptInHome() { async function continuePendingHomePromptInHome() {
...@@ -3433,6 +3802,72 @@ export default function App() { ...@@ -3433,6 +3802,72 @@ export default function App() {
} }
} }
function acceptComposerAttachmentFile(file: File) {
const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) {
setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。");
return;
}
if (file.type && !file.type.startsWith("image/")) {
setErrorText("当前附件只支持图片。");
return;
}
setErrorText("");
setComposerAttachment({
kind: "image",
name: file.name || localPath.split(/[\\/]/).pop() || "image",
mimeType: file.type || "application/octet-stream",
localPath
});
}
function handleComposerDragEnter(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current += 1;
setIsComposerDragOver(true);
}
function handleComposerDragOver(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
function handleComposerDragLeave(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = Math.max(0, composerDragDepthRef.current - 1);
if (composerDragDepthRef.current === 0) {
setIsComposerDragOver(false);
}
}
function handleComposerDrop(event: ReactDragEvent<HTMLFormElement>) {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
composerDragDepthRef.current = 0;
setIsComposerDragOver(false);
const file = event.dataTransfer.files[0];
if (file) {
acceptComposerAttachmentFile(file);
}
}
async function openAttachmentPicker() { async function openAttachmentPicker() {
if (window.qjcDesktop) { if (window.qjcDesktop) {
const attachment = await desktopApi.chat.pickImageAttachment(); const attachment = await desktopApi.chat.pickImageAttachment();
...@@ -3453,6 +3888,11 @@ export default function App() { ...@@ -3453,6 +3888,11 @@ export default function App() {
return; return;
} }
acceptComposerAttachmentFile(file);
event.target.value = "";
return;
/*
const localPath = (file as File & { path?: string }).path?.trim(); const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) { if (!localPath) {
setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。"); setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。");
...@@ -3485,9 +3925,35 @@ export default function App() { ...@@ -3485,9 +3925,35 @@ export default function App() {
mimeType: file.type || "application/octet-stream", mimeType: file.type || "application/octet-stream",
localPath localPath
}); });
*/
event.target.value = ""; event.target.value = "";
} }
async function regenerateAssistantMessage(messageId: string) {
if (sending || !visibleSessionId || !sessionScopeProjectId) {
return;
}
const targetIndex = messages.findIndex((message) => message.id === messageId);
if (targetIndex < 0) {
return;
}
const previousUserMessage = [...messages.slice(0, targetIndex)].reverse().find((message) => message.role === "user" && message.content.trim());
if (!previousUserMessage) {
return;
}
await submitPrompt(previousUserMessage.content, selectedSkill.id, visibleSessionId, sessionScopeProjectId);
}
function toggleMessageReaction(messageId: string, reaction: MessageReaction) {
setMessageReactions((current) => ({
...current,
[messageId]: current[messageId] === reaction ? undefined : reaction
}));
}
function chooseSkill(skillId: string) { function chooseSkill(skillId: string) {
setSelectedSkillId(skillId); setSelectedSkillId(skillId);
setSkillMenuOpen(false); setSkillMenuOpen(false);
...@@ -3506,6 +3972,7 @@ export default function App() { ...@@ -3506,6 +3972,7 @@ export default function App() {
function openSession(sessionId: string) { function openSession(sessionId: string) {
setViewMode((current) => (current === "experts" ? "experts" : "chat")); setViewMode((current) => (current === "experts" ? "experts" : "chat"));
setActiveSessionId(sessionId); setActiveSessionId(sessionId);
setSessionActionMenuId("");
} }
async function switchExpert(projectId: string) { async function switchExpert(projectId: string) {
...@@ -3587,14 +4054,15 @@ export default function App() { ...@@ -3587,14 +4054,15 @@ export default function App() {
const sidebarSessionLabel = viewMode === "experts" ? "专家会话" : "会话管理"; const sidebarSessionLabel = viewMode === "experts" ? "专家会话" : "会话管理";
const selectedSkillBadge = selectedSkillId === DEFAULT_SKILL.id ? "千匠问天" : "@" + selectedSkill.name; const selectedSkillBadge = selectedSkillId === DEFAULT_SKILL.id ? "千匠问天" : "@" + selectedSkill.name;
const panelNewSessionAction = ( const sidebarNewSessionAction = (
<button <button
type="button" type="button"
className="secondary conversation-new-session" className="sidebar-new-session app-no-drag !flex !min-h-12 !items-center !justify-center !gap-2 !rounded-[18px] !border !border-[#d7e8ff] !bg-white !px-4 !text-[14px] !font-semibold !text-[#1d4ed8] !shadow-[0_14px_30px_rgba(59,130,246,0.08)] transition hover:!border-[#bfdbfe] hover:!bg-[#f8fbff]"
disabled={projectActionPending || !isBound || !projects.length} disabled={projectActionPending || !isBound || !projects.length}
onClick={() => void createProjectSession()} onClick={() => void createProjectSession()}
> >
<span className="conversation-new-session-label">新建对话</span> <span className="text-lg leading-none">+</span>
<span className="conversation-new-session-label">新对话</span>
</button> </button>
); );
const conversationPanelTitle = viewMode === "experts" ? activeExpertName : "对话"; const conversationPanelTitle = viewMode === "experts" ? activeExpertName : "对话";
...@@ -3603,7 +4071,7 @@ export default function App() { ...@@ -3603,7 +4071,7 @@ export default function App() {
<span className="home-microcopy-icon"> <span className="home-microcopy-icon">
<LobsterClawIcon /> <LobsterClawIcon />
</span> </span>
<span className="home-microcopy-text">{homeChatCopy.microcopy}</span> <span className="home-microcopy-text">当前对话</span>
<span className="home-microcopy-tag">{selectedSkillBadge}</span> <span className="home-microcopy-tag">{selectedSkillBadge}</span>
</div> </div>
) : ( ) : (
...@@ -3636,11 +4104,7 @@ export default function App() { ...@@ -3636,11 +4104,7 @@ export default function App() {
</div> </div>
); );
const isDouyinExpertGuide = viewMode === "experts" && activeExpertKey === "douyin"; const isDouyinExpertGuide = viewMode === "experts" && activeExpertKey === "douyin";
const composerPlaceholder = isBound const composerPlaceholder = isBound ? "" : ui.taskDisabledPlaceholder;
? viewMode === "experts"
? activeExpertGuide.placeholder ?? ui.taskPlaceholder
: ui.taskPlaceholder
: ui.taskDisabledPlaceholder;
const activeEmptyState = viewMode === "experts" ? ( const activeEmptyState = viewMode === "experts" ? (
isDouyinExpertGuide ? ( isDouyinExpertGuide ? (
<div className="empty-state expert-empty-state expert-empty-state-douyin"> <div className="empty-state expert-empty-state expert-empty-state-douyin">
...@@ -3720,16 +4184,19 @@ export default function App() { ...@@ -3720,16 +4184,19 @@ export default function App() {
); );
const messageListContent = ( const messageListContent = (
<div className={"message-list" + (viewMode === "chat" ? " message-list-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " message-list-xiaohongshu" : "")}> <div className={"message-list chat-scroll-smooth !flex !min-h-0 !w-full !flex-1 !flex-col !gap-7 !overflow-y-auto !bg-transparent !px-0 !py-3" + (viewMode === "chat" ? " message-list-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " message-list-xiaohongshu" : "")}>
{messages.map((message) => { {messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim(); const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null; const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null;
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined; const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined;
const hasTrace = Boolean(messageTrace?.items.length); const hasTrace = Boolean(messageTrace?.items.length);
const isTraceExpanded = Boolean(messageTrace?.expanded); const isTraceExpanded = Boolean(messageTrace?.expanded);
const canCopyMessage = Boolean(message.content.trim());
const copyToken = `message:${message.id}`;
const reaction = messageReactions[message.id];
return ( return (
<article key={message.id} className={"message-card " + message.role + (message.streamState ? " " + message.streamState : "")}> <article key={message.id} className={"message-card group relative !w-full !max-w-full !min-w-0 " + message.role + (message.streamState ? " " + message.streamState : "") + (message.role === "user" ? " !flex !justify-end" : " !flex !justify-start")}>
<div className="message-bubble"> <div className={"message-bubble " + (message.role === "assistant" ? "!w-full !max-w-full !min-w-0 !rounded-none !border-0 !bg-transparent !pl-[2ch] !pr-0 !py-0 !shadow-none" : "animate-user-bubble-in !ml-auto !inline-flex !w-fit !max-w-[min(82%,720px)] !min-w-0 !flex-col !rounded-[20px] !border !border-[#dbeafe] !bg-[#f0f7ff] !px-5 !py-4 !shadow-[0_12px_30px_rgba(59,130,246,0.08)]")}>
{showThinking ? ( {showThinking ? (
videoStatusCard ? ( videoStatusCard ? (
<div className="generation-status-card" aria-live="polite"> <div className="generation-status-card" aria-live="polite">
...@@ -3752,10 +4219,21 @@ export default function App() { ...@@ -3752,10 +4219,21 @@ export default function App() {
</div> </div>
) )
) : message.content ? ( ) : message.content ? (
<p> message.role === "assistant" ? (
{message.content} <div className="markdown-body !gap-4 text-[15px] leading-8 text-[#0f172a]">
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null} {renderMarkdownContent(message.content, {
</p> messageId: message.id,
copiedToken,
onCopy: handleCopyText
})}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</div>
) : (
<p className="message-plain-text !m-0 text-[15px] leading-8 text-[#0f172a]">
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
)
) : null} ) : null}
{hasTrace ? ( {hasTrace ? (
<div className="message-trace"> <div className="message-trace">
...@@ -3776,6 +4254,51 @@ export default function App() { ...@@ -3776,6 +4254,51 @@ export default function App() {
</div> </div>
) : null} ) : null}
</div> </div>
{message.role === "assistant" && canCopyMessage ? (
<div className="message-card-actions !mt-3 !justify-start !opacity-0 !transition !duration-150 group-hover:!opacity-100">
<button
type="button"
className={"message-action-icon !h-8 !w-8 !rounded-full !border-0 !bg-white !text-[#64748b] !shadow-[0_10px_24px_rgba(148,163,184,0.18)] hover:!bg-[#f8fbff] " + (copiedToken === copyToken ? " copied !bg-[#ecfdf3] !text-[#16a34a]" : "")}
onClick={() => void handleCopyText(copyToken, message.content)}
aria-label="复制消息"
title="复制消息"
>
{copiedToken === copyToken ? <CheckIcon /> : <CopyIcon />}
</button>
{message.role === "assistant" ? (
<>
<button
type="button"
className="hidden"
onClick={() => void regenerateAssistantMessage(message.id)}
disabled={sending}
aria-label="重新生成"
title="重新生成"
>
<RefreshIcon />
</button>
<button
type="button"
className="hidden"
onClick={() => toggleMessageReaction(message.id, "up")}
aria-label="赞"
title="赞"
>
<ThumbIcon direction="up" />
</button>
<button
type="button"
className="hidden"
onClick={() => toggleMessageReaction(message.id, "down")}
aria-label="踩"
title="踩"
>
<ThumbIcon direction="down" />
</button>
</>
) : null}
</div>
) : null}
</article> </article>
); );
})} })}
...@@ -3828,7 +4351,17 @@ export default function App() { ...@@ -3828,7 +4351,17 @@ export default function App() {
: messageListContent; : messageListContent;
const composerContent = ( const composerContent = (
<div className={"composer-shell" + (viewMode === "chat" ? " composer-shell-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " composer-shell-xiaohongshu" : "")}> <form
className={"composer-shell relative !mt-2 !flex !w-full !flex-col !gap-3 !rounded-[24px] !border !border-[#d7e8ff] !bg-white !px-5 !py-4 !shadow-[0_24px_60px_rgba(148,163,184,0.14)]" + (isComposerDragOver ? " dragging !border-[#60a5fa] !bg-[#f8fbff]" : "") + (viewMode === "chat" ? " composer-shell-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " composer-shell-xiaohongshu" : "")}
onSubmit={(event) => {
event.preventDefault();
void sendPrompt();
}}
onDragEnter={handleComposerDragEnter}
onDragOver={handleComposerDragOver}
onDragLeave={handleComposerDragLeave}
onDrop={handleComposerDrop}
>
<input <input
ref={attachmentInputRef} ref={attachmentInputRef}
className="composer-attachment-input" className="composer-attachment-input"
...@@ -3837,18 +4370,20 @@ export default function App() { ...@@ -3837,18 +4370,20 @@ export default function App() {
tabIndex={-1} tabIndex={-1}
onChange={handleAttachmentSelection} onChange={handleAttachmentSelection}
/> />
<label className="composer-field"> {isComposerDragOver ? <div className="composer-drop-indicator !min-h-14 !rounded-[18px] !border-dashed !border-[#93c5fd] !bg-[#f0f7ff] !text-[#2563eb]">释放以上传图片</div> : null}
<label className="composer-field !gap-0">
<textarea <textarea
value={prompt} value={prompt}
disabled={!isBound} disabled={!isBound}
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
onKeyDown={(event) => void handleComposerKeyDown(event)} onKeyDown={(event) => void handleComposerKeyDown(event)}
placeholder={composerPlaceholder} placeholder={composerPlaceholder}
className="!min-h-[60px] !rounded-none !border-0 !bg-transparent !p-0 !text-[15px] !leading-8 !text-[#0f172a] placeholder:!text-transparent"
/> />
</label> </label>
{composerAttachment ? ( {composerAttachment ? (
<div className="composer-attachment-strip"> <div className="composer-attachment-strip !mt-0">
<span className="composer-attachment-chip"> <span className="composer-attachment-chip !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !px-3 !py-2">
<span className="composer-attachment-chip-label">{composerAttachment.name}</span> <span className="composer-attachment-chip-label">{composerAttachment.name}</span>
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件"> <button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件">
x x
...@@ -3856,14 +4391,17 @@ export default function App() { ...@@ -3856,14 +4391,17 @@ export default function App() {
</span> </span>
</div> </div>
) : null} ) : null}
<div className="composer-footer"> <div className="composer-footer !items-end !justify-between !gap-4">
<div className="composer-left-tools" ref={skillMenuRef}> <div className="composer-left-tools !flex-1 !items-center !gap-2" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only !h-11 !w-11 !rounded-full !border !border-[#d7e8ff] !bg-[#f0f7ff] !text-[#2563eb] hover:!bg-[#e0f2fe]" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传图片" title="上传图片">
<AttachmentIcon />
</button>
{viewMode === "experts" ? ( {viewMode === "experts" ? (
<button type="button" className="attachment-trigger" disabled={!isBound || sending} onClick={openAttachmentPicker}> <button type="button" className="attachment-trigger" disabled={!isBound || sending} onClick={openAttachmentPicker}>
图片 图片
</button> </button>
) : null} ) : null}
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}> <button type="button" className="skill-trigger !h-11 !rounded-full !border !border-[#e2e8f0] !bg-white !px-4 !text-[#334155]" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
@ @
</button> </button>
{selectedSkillId !== DEFAULT_SKILL.id ? ( {selectedSkillId !== DEFAULT_SKILL.id ? (
...@@ -3892,16 +4430,23 @@ export default function App() { ...@@ -3892,16 +4430,23 @@ export default function App() {
</div> </div>
) : null} ) : null}
</div> </div>
<button className="composer-submit" disabled={!canSend} onClick={() => void sendPrompt()} aria-label={sendButtonLabel} title={sendButtonLabel}> <button
<SendArrowIcon /> type="submit"
className={"composer-submit !h-12 !w-12 !rounded-full !border-0 !bg-[#2563eb] !text-white !shadow-[0_16px_30px_rgba(37,99,235,0.28)] hover:!bg-[#1d4ed8] " + (sending ? " is-busy" : "")}
disabled={!canSend}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
{sending ? <span className="composer-submit-spinner" aria-hidden="true" /> : <ArrowUpIcon />}
<span className="visually-hidden">{sendButtonLabel}</span> <span className="visually-hidden">{sendButtonLabel}</span>
</button> </button>
</div> </div>
</div> <p className="composer-hint !m-0 !text-[11px] !text-[#94a3b8]">按 Enter 发送,Shift + Enter 换行</p>
</form>
); );
return ( return (
<div className="shell openclaw-theme"> <div className="shell openclaw-theme !grid !grid-cols-[280px_minmax(0,1fr)] !bg-[#f0f7ff]">
<div className="window-controls" aria-label="窗口控制"> <div className="window-controls" aria-label="窗口控制">
<button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}> <button type="button" className="window-control-button" aria-label="最小化窗口" onClick={minimizeWindow}>
<WindowControlIcon kind="minimize" /> <WindowControlIcon kind="minimize" />
...@@ -3913,9 +4458,9 @@ export default function App() { ...@@ -3913,9 +4458,9 @@ export default function App() {
<WindowControlIcon kind="close" /> <WindowControlIcon kind="close" />
</button> </button>
</div> </div>
<aside className="sidebar"> <aside className="sidebar app-drag-region !w-[280px] !border-r !border-[#dbeafe] !bg-[#f9fbff] !px-4 !py-5">
<div className="sidebar-top"> <div className="sidebar-top !gap-4">
<div className="sidebar-logo-block" aria-label="千匠问天"> <div className="sidebar-logo-block !rounded-[20px] !border !border-[#dbeafe] !bg-white !px-4 !py-4 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]" aria-label="千匠问天">
<div className="sidebar-logo-mark-shell" aria-hidden="true"> <div className="sidebar-logo-mark-shell" aria-hidden="true">
<img src={brandIcon} alt="" className="sidebar-logo-mark" /> <img src={brandIcon} alt="" className="sidebar-logo-mark" />
</div> </div>
...@@ -3923,14 +4468,14 @@ export default function App() { ...@@ -3923,14 +4468,14 @@ export default function App() {
<strong>千匠问天</strong> <strong>千匠问天</strong>
</div> </div>
</div> </div>
<nav className="nav-list"> <nav className="nav-list !gap-2">
{[ {[
{ id: "chat" as const, label: "对话" }, { id: "chat" as const, label: "对话" },
{ id: "experts" as const, label: ui.experts }, { id: "experts" as const, label: ui.experts },
{ id: "plugins" as const, label: ui.plugins }, { id: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings } { id: "settings" as const, label: ui.settings }
].map((item) => ( ].map((item) => (
<button key={item.id} type="button" className={"nav-item" + (viewMode === item.id ? " active" : "")} onClick={() => void handleNavSelection(item.id)}> <button key={item.id} type="button" className={"nav-item app-no-drag !min-h-12 !rounded-[18px] !px-4 !text-[14px] !font-medium !shadow-none transition " + (viewMode === item.id ? " active !bg-[#f0f7ff] !text-[#1d4ed8] !shadow-[inset_0_0_0_1px_rgba(191,219,254,0.95)]" : "!bg-transparent !text-[#334155] hover:!bg-white hover:!text-[#0f172a]")} onClick={() => void handleNavSelection(item.id)}>
<span className="nav-item-icon" aria-hidden="true"> <span className="nav-item-icon" aria-hidden="true">
<NavIcon kind={item.id} /> <NavIcon kind={item.id} />
</span> </span>
...@@ -3938,12 +4483,13 @@ export default function App() { ...@@ -3938,12 +4483,13 @@ export default function App() {
</button> </button>
))} ))}
</nav> </nav>
{!showBindEntry ? sidebarNewSessionAction : null}
</div> </div>
<div className="sidebar-bottom"> <div className="sidebar-bottom !gap-4">
<section className="sidebar-section compact sidebar-experts-entry"> <section className="sidebar-section compact sidebar-experts-entry !rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]">
{sidebarExpertEntries.length ? ( {sidebarExpertEntries.length ? (
<div className="sidebar-expert-scroll"> <div className="sidebar-expert-scroll">
<div className="expert-chip-list preview"> <div className="expert-chip-list preview !gap-2">
{sidebarExpertEntries.map((entry) => { {sidebarExpertEntries.map((entry) => {
const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition); const expertVisualKey = resolveExpertVisualKey(entry.project, entry.definition);
const isStandalone = entry.definition.entryMode === "standalone"; const isStandalone = entry.definition.entryMode === "standalone";
...@@ -3954,7 +4500,7 @@ export default function App() { ...@@ -3954,7 +4500,7 @@ export default function App() {
<button <button
key={entry.definition.id} key={entry.definition.id}
type="button" type="button"
className={"expert-chip expert-chip-" + expertVisualKey + (isActive ? " active" : "")} className={"expert-chip expert-chip-" + expertVisualKey + " app-no-drag !min-h-14 !rounded-[18px] !border !px-3 !py-3 !shadow-none transition " + (isActive ? " active !border-[#bfdbfe] !bg-[#f0f7ff]" : "!border-transparent !bg-[#f8fbff] hover:!border-[#dbeafe] hover:!bg-white")}
disabled={projectActionPending || !entry.isAvailable} disabled={projectActionPending || !entry.isAvailable}
onClick={() => { onClick={() => {
if (isStandalone) { if (isStandalone) {
...@@ -3980,28 +4526,42 @@ export default function App() { ...@@ -3980,28 +4526,42 @@ export default function App() {
) : null} ) : null}
</section> </section>
{!showBindEntry ? ( {!showBindEntry ? (
<section className="sidebar-section sidebar-section-fill compact sidebar-session-section"> <section className="sidebar-section sidebar-section-fill compact sidebar-session-section !rounded-[20px] !border !border-[#dbeafe] !bg-white !p-3 !shadow-[0_18px_36px_rgba(148,163,184,0.08)]">
<div className="sidebar-section-head sidebar-section-head-subtle"> <div className="sidebar-section-head sidebar-section-head-subtle">
<div className="sidebar-section-copy"> <div className="sidebar-section-copy">
<span className="sidebar-section-label">{sidebarSessionLabel}</span> <span className="sidebar-section-label">{sidebarSessionLabel}</span>
</div> </div>
</div> </div>
<div className="sidebar-session-list"> <div className="sidebar-session-list !gap-2">
{sessions.map((session, index) => ( {sessions.map((session, index) => (
<div key={session.id} className={"sidebar-session-card" + (activeSessionId === session.id ? " active" : "")}> <div key={session.id} className={"sidebar-session-card !rounded-[16px]" + (activeSessionId === session.id ? " active !bg-[#f0f7ff]" : " hover:!bg-[#f8fbff]")}>
<button type="button" className="sidebar-session-main" disabled={projectActionPending} onClick={() => openSession(session.id)}> <button type="button" className="sidebar-session-main app-no-drag !min-h-12 !rounded-[16px] !px-3 !text-left !shadow-none" disabled={projectActionPending} onClick={() => openSession(session.id)}>
<strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong> <strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button> </button>
{sessions.length > 1 ? ( {sessions.length > 1 ? (
<button <div className="sidebar-session-actions">
type="button" <button
className="sidebar-session-close" type="button"
aria-label={ui.closeSession} className={"sidebar-session-close" + (sessionActionMenuId === session.id ? " active" : "")}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)} aria-label="会话操作"
onClick={() => void closeProjectSession(session.id)} disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
> onClick={() => setSessionActionMenuId((current) => current === session.id ? "" : session.id)}
x >
</button> <MoreIcon />
</button>
{sessionActionMenuId === session.id ? (
<div className="sidebar-session-menu">
<button
type="button"
className="sidebar-session-menu-item"
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
{ui.closeSession}
</button>
</div>
) : null}
</div>
) : null} ) : null}
</div> </div>
))} ))}
...@@ -4010,13 +4570,14 @@ export default function App() { ...@@ -4010,13 +4570,14 @@ export default function App() {
) : null} ) : null}
</div> </div>
</aside> </aside>
<div className="main-shell"> <div className="main-shell !bg-[#f0f7ff]">
{!isConversationView ? ( {!isConversationView ? (
<div className="page-topbar"> <div className="page-topbar">
<div className="page-copy"> <div className="page-copy">
<h2>{pageTitle}</h2> <h2>{pageTitle}</h2>
<p>{pageDesc}</p> <p>{pageDesc}</p>
</div> </div>
<div className="page-drag-strip" aria-hidden="true" />
<div className="header-actions"> <div className="header-actions">
<StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip> <StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null} {isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
...@@ -4027,18 +4588,18 @@ export default function App() { ...@@ -4027,18 +4588,18 @@ export default function App() {
{errorText ? <div className="notice error">{errorText}</div> : null} {errorText ? <div className="notice error">{errorText}</div> : null}
<main className="content-area"> <main className="content-area">
{isConversationView ? ( {isConversationView ? (
<section className={"panel chat-panel conversation-panel" + (viewMode === "chat" ? " conversation-panel-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-xiaohongshu" : "")}> <section className={"panel chat-panel conversation-panel !gap-5 !rounded-none !bg-transparent !px-7 !py-6" + (viewMode === "chat" ? " conversation-panel-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-xiaohongshu" : "")}>
<div className="conversation-panel-head"> <div className="conversation-panel-head conversation-panel-head-layout app-drag-region !grid !items-center !gap-4 !border-0 !pb-0">
<div className="conversation-panel-copy"> <div className="conversation-panel-copy">
{conversationPanelLead} {conversationPanelLead}
</div> </div>
<div className="conversation-panel-actions"> <div className="conversation-drag-strip" aria-hidden="true" />
<div className="conversation-panel-actions app-no-drag !flex !items-center !gap-2">
<StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip> <StatusChip tone={workspaceStatusTone}>{workspaceStatusLabel}</StatusChip>
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null} {isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
{!showBindEntry ? panelNewSessionAction : null}
</div> </div>
</div> </div>
<div className={"conversation-panel-body" + (viewMode === "chat" ? " conversation-panel-body-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-body-xiaohongshu" : "")}> <div className={"conversation-panel-body !flex !min-h-0 !flex-1 !flex-col !overflow-hidden !rounded-[28px] !border !border-[#dbeafe] !bg-white/78 !px-0 !py-6 !shadow-[0_24px_60px_rgba(148,163,184,0.12)] backdrop-blur" + (viewMode === "chat" ? " conversation-panel-body-home" : "") + (viewMode === "experts" && activeExpertKey === "xiaohongshu" ? " conversation-panel-body-xiaohongshu" : "")}>
{conversationStatusNotice} {conversationStatusNotice}
{viewMode === "chat" ? homeIntentSuggestionNotice : null} {viewMode === "chat" ? homeIntentSuggestionNotice : null}
{conversationBodyContent} {conversationBodyContent}
...@@ -4076,6 +4637,7 @@ export default function App() { ...@@ -4076,6 +4637,7 @@ export default function App() {
) : null} ) : null}
{viewMode === "settings" ? ( {viewMode === "settings" ? (
<div className="page-stack settings-page-stack"> <div className="page-stack settings-page-stack">
{showSettingsStatusHint ? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
<section className="panel settings-panel settings-panel-hero compact settings-panel-modern"> <section className="panel settings-panel settings-panel-hero compact settings-panel-modern">
<div className="settings-section-card"> <div className="settings-section-card">
<div className="settings-section-headline"> <div className="settings-section-headline">
...@@ -4085,7 +4647,8 @@ export default function App() { ...@@ -4085,7 +4647,8 @@ export default function App() {
</div> </div>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label> <label className="settings-input-label">
<span className="settings-input-label-text">员工密钥</span>
<input <input
type="password" type="password"
value={lobsterKeyDraft} value={lobsterKeyDraft}
...@@ -4095,7 +4658,7 @@ export default function App() { ...@@ -4095,7 +4658,7 @@ export default function App() {
</label> </label>
</div> </div>
<div className="button-row settings-actions"> <div className="button-row settings-actions">
<button disabled={saving || lobsterKeyDraft.trim().length === 0} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button> <button disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
</div> </div>
</div> </div>
<div className="settings-section-card"> <div className="settings-section-card">
...@@ -4116,8 +4679,8 @@ export default function App() { ...@@ -4116,8 +4679,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip> <StatusChip tone={config?.expertModelConfig.copywriting.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.copywriting.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label> <label className="settings-input-label">
api_key <span className="settings-input-label-text">API Key</span>
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} /> <input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -4131,8 +4694,8 @@ export default function App() { ...@@ -4131,8 +4694,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip> <StatusChip tone={config?.expertModelConfig.image.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.image.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label> <label className="settings-input-label">
api_key <span className="settings-input-label-text">API Key</span>
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} /> <input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -4146,8 +4709,8 @@ export default function App() { ...@@ -4146,8 +4709,8 @@ export default function App() {
<StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip> <StatusChip tone={config?.expertModelConfig.video.apiKeyConfigured ? "positive" : "warning"}>{config?.expertModelConfig.video.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label> <label className="settings-input-label">
api_key <span className="settings-input-label-text">API Key</span>
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} /> <input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -4175,27 +4738,27 @@ export default function App() { ...@@ -4175,27 +4738,27 @@ export default function App() {
</StatusChip> </StatusChip>
</div> </div>
<div className="settings-field-grid"> <div className="settings-field-grid">
<label> <label className="settings-input-label">
VOLC_ACCESS_KEY <span className="settings-input-label-text">VOLC_ACCESS_KEY</span>
<input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} /> <input type="password" value={digitalHumanVolcAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setDigitalHumanVolcAccessKeyDraft(event.target.value)} />
</label> </label>
<label> <label className="settings-input-label">
VOLC_SECRET_KEY <span className="settings-input-label-text">VOLC_SECRET_KEY</span>
<input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} /> <input type="password" value={digitalHumanVolcSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setDigitalHumanVolcSecretKeyDraft(event.target.value)} />
</label> </label>
<label> <label className="settings-input-label">
QINIU_ACCESS_KEY <span className="settings-input-label-text">QINIU_ACCESS_KEY</span>
<input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} /> <input type="password" value={digitalHumanQiniuAccessKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setDigitalHumanQiniuAccessKeyDraft(event.target.value)} />
</label> </label>
<label> <label className="settings-input-label">
QINIU_SECRET_KEY <span className="settings-input-label-text">QINIU_SECRET_KEY</span>
<input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} /> <input type="password" value={digitalHumanQiniuSecretKeyDraft} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setDigitalHumanQiniuSecretKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
</article> </article>
</div> </div>
<div className="button-row settings-actions"> <div className="button-row settings-actions">
<button disabled={saving} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button> <button disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
</div> </div>
</div> </div>
</section> </section>
...@@ -4319,14 +4882,13 @@ export default function App() { ...@@ -4319,14 +4882,13 @@ export default function App() {
<p>{ui.diagnosticsDesc}</p> <p>{ui.diagnosticsDesc}</p>
</div> </div>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-static-list">
<label> <div className="settings-static-item">
{ui.workspacePath} <span>{ui.workspacePath}</span>
<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /> <strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong>
</label> </div>
</div> </div>
<div className="diagnostic-meta-list"> <div className="diagnostic-meta-list">
<div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div>
{runtimeCloudStatus ? ( {runtimeCloudStatus ? (
<> <>
<div className="mini-info"><span>Runtime Cloud Target</span><strong>{runtimeCloudStatus.baseUrl || ui.none}</strong></div> <div className="mini-info"><span>Runtime Cloud Target</span><strong>{runtimeCloudStatus.baseUrl || ui.none}</strong></div>
...@@ -4336,7 +4898,9 @@ export default function App() { ...@@ -4336,7 +4898,9 @@ export default function App() {
) : null} ) : null}
</div> </div>
<div className="button-row settings-actions"> <div className="button-row settings-actions">
<button disabled={saving} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存工作区设置"}</button> <button className="secondary" onClick={() => void handleCopyText("workspace-path", config?.workspacePath || workspacePathDraft || ui.none)}>
{copiedToken === "workspace-path" ? ui.copied : ui.copy}
</button>
<button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button> <button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div> </div>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment