Commit 29bb5e3a authored by AI-甘富林's avatar AI-甘富林

UI交互修改 v1.0

parent 3815629f
import { BrowserWindow, app } from "electron"; import { BrowserWindow, app } from "electron";
import path from "node:path"; import path from "node:path";
function resolveRendererEntry(): string { function resolveRendererEntry(): string {
...@@ -14,8 +14,8 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow { ...@@ -14,8 +14,8 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1400, width: 1400,
height: 920, height: 920,
minWidth: 1080, minWidth: 960,
minHeight: 720, minHeight: 640,
backgroundColor: "#0f172a", backgroundColor: "#0f172a",
webPreferences: { webPreferences: {
additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [], additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [],
...@@ -35,3 +35,4 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow { ...@@ -35,3 +35,4 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
return window; return window;
} }
...@@ -21,6 +21,7 @@ type ViewMode = "chat" | "skills" | "plugins" | "settings"; ...@@ -21,6 +21,7 @@ type ViewMode = "chat" | "skills" | "plugins" | "settings";
type Tone = "positive" | "warning"; type Tone = "positive" | "warning";
const DEFAULT_SESSION_ID = "desktop-main"; const DEFAULT_SESSION_ID = "desktop-main";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const DEFAULT_SKILL = { const DEFAULT_SKILL = {
id: "default-chat", id: "default-chat",
name: "默认对话", name: "默认对话",
...@@ -30,16 +31,16 @@ const DEFAULT_SKILL = { ...@@ -30,16 +31,16 @@ const DEFAULT_SKILL = {
}; };
const ui = { const ui = {
app: "钱江爪", app: "千匠Claw",
subtitle: "OpenClaw Client", subtitle: "OpenClaw Client",
appDesc: "绑定 api_key 后自动配置运行时", appDesc: "绑定 api_key 后自动拉取运行时配置",
heroLine: "千匠Claw,您身边最得力的员工,Start Your Ideas....",
chat: "对话", chat: "对话",
skills: "技能", skills: "技能",
plugins: "插件", plugins: "插件",
settings: "设置", settings: "设置",
bound: "已绑定", bound: "已绑定",
unbound: "未绑定", unbound: "未绑定",
preparing: "准备中",
defaultChat: "默认对话", defaultChat: "默认对话",
bindTitle: "绑定员工密钥", bindTitle: "绑定员工密钥",
bindDesc: "输入 OpenClaw employee api_key 后,桌面端会自动拉取运行时配置。", bindDesc: "输入 OpenClaw employee api_key 后,桌面端会自动拉取运行时配置。",
...@@ -48,17 +49,16 @@ const ui = { ...@@ -48,17 +49,16 @@ const ui = {
bindNow: "立即绑定", bindNow: "立即绑定",
binding: "绑定中...", binding: "绑定中...",
changeApiKey: "更换员工密钥", changeApiKey: "更换员工密钥",
bindingManagedInSettings: "员工密钥已在设置中管理",
skillChoice: "选择技能", skillChoice: "选择技能",
noMessages: "当前没有消息,请先发送一条消息。", noMessages: "当前没有消息,请先发送一条消息。",
taskPlaceholder: "输入消息后回车或点击发送", taskPlaceholder: "输入消息后回车或点击发送",
taskDisabledPlaceholder: "请先绑定员工密钥后开始对话。",
send: "发送", send: "发送",
sending: "发送中...", sending: "发送中...",
bindFirst: "请先绑定", bindFirst: "请先绑定",
bindFirstError: "请先绑定员工密钥后再发送消息。", bindFirstError: "请先绑定员工密钥后再发送消息。",
refresh: "刷新",
startingHint: "运行时正在启动,请稍候。", startingHint: "运行时正在启动,请稍候。",
chatNotReadyError: "当前聊天不可用,请检查运行时状态。", chatNotReadyError: "当前聊天不可用,请检查运行时状态。",
noSkillCards: "当前没有可用技能。", noSkillCards: "当前没有可用技能。",
pluginTitle: "插件列表", pluginTitle: "插件列表",
noPlugins: "当前没有可用插件。", noPlugins: "当前没有可用插件。",
...@@ -86,7 +86,7 @@ const pluginDisplayMap: Record<string, { name: string; description: string }> = ...@@ -86,7 +86,7 @@ const pluginDisplayMap: Record<string, { name: string; description: string }> =
"runtime-diagnostics": { name: "运行时诊断", description: "查看运行时信息、日志和状态。" }, "runtime-diagnostics": { name: "运行时诊断", description: "查看运行时信息、日志和状态。" },
"browser-automation": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" }, "browser-automation": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"browser-plugin": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" }, "browser-plugin": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"ocr-tools": { name: "OCR 识别", description: "识别扫描件和图片文字并提取结。" } "ocr-tools": { name: "OCR 识别", description: "识别扫描件和图片文字并提取结。" }
}; };
const mockDesktopApi = { const mockDesktopApi = {
...@@ -166,12 +166,32 @@ const smokeEnabled = window.qjcSmokeEnabled === true; ...@@ -166,12 +166,32 @@ const smokeEnabled = window.qjcSmokeEnabled === true;
declare global { declare global {
interface Window { interface Window {
qjcDesktop?: DesktopApi;
qjcSmokeEnabled?: boolean; qjcSmokeEnabled?: boolean;
__QJC_SMOKE__?: { usingMockApi: boolean; gatewayStatus: GatewayStatus | null; gatewayHealth: GatewayHealth | null; runtimeStatus: RuntimeStatus | null; runtimeCloudStatus: RuntimeCloudStatus | null; runtimeTelemetry: RuntimeTelemetryStatus | null; config: AppConfig | null; authSession: null; profile: null; credits: null; skills: WorkspaceSummary["skills"]; modelConfig: null; systemSummary: SystemSummary | null; sessions: SessionSummary[]; messages: ChatMessage[]; logs: LogEntry[]; activeSessionId: string; workspaceSummary: WorkspaceSummary | null; }; __QJC_SMOKE__?: {
usingMockApi: boolean;
gatewayStatus: GatewayStatus | null;
gatewayHealth: GatewayHealth | null;
runtimeStatus: RuntimeStatus | null;
runtimeCloudStatus: RuntimeCloudStatus | null;
runtimeTelemetry: RuntimeTelemetryStatus | null;
config: AppConfig | null;
authSession: null;
profile: null;
credits: null;
skills: WorkspaceSummary["skills"];
modelConfig: null;
systemSummary: SystemSummary | null;
sessions: SessionSummary[];
messages: ChatMessage[];
logs: LogEntry[];
activeSessionId: string;
workspaceSummary: WorkspaceSummary | null;
};
} }
} }
const err = (value: unknown) => value instanceof Error ? value.message : String(value); const err = (value: unknown) => (value instanceof Error ? value.message : String(value));
function StatusChip({ tone, children }: { tone: Tone; children: string }) { function StatusChip({ tone, children }: { tone: Tone; children: string }) {
return <span className={"status-chip " + tone}>{children}</span>; return <span className={"status-chip " + tone}>{children}</span>;
...@@ -210,22 +230,37 @@ export default function App() { ...@@ -210,22 +230,37 @@ export default function App() {
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState(""); const [infoText, setInfoText] = useState("");
const effectiveSkills = useMemo(() => { const effectiveSkills = useMemo(() => (workspace?.skills?.length ? workspace.skills : [DEFAULT_SKILL]), [workspace]);
return workspace?.skills?.length ? workspace.skills : [DEFAULT_SKILL];
}, [workspace]);
const selectedSkill = useMemo(() => effectiveSkills.find((skill) => skill.id === selectedSkillId) ?? effectiveSkills[0] ?? DEFAULT_SKILL, [effectiveSkills, selectedSkillId]); const selectedSkill = useMemo(() => effectiveSkills.find((skill) => skill.id === selectedSkillId) ?? effectiveSkills[0] ?? DEFAULT_SKILL, [effectiveSkills, selectedSkillId]);
const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound"); const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound");
const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : ""); const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : "");
const sessions = workspace?.apiKeyConfigured ? [{ id: activeSessionId, title: ui.defaultChat, updatedAt: new Date().toISOString() }] : []; const sessions = workspace?.apiKeyConfigured ? [{ id: activeSessionId, title: ui.defaultChat, updatedAt: new Date().toISOString() }] : [];
const canSend = Boolean(workspace?.apiKeyConfigured) && prompt.trim().length > 0 && !sending && !saving; const isBound = Boolean(workspace?.apiKeyConfigured);
const sendButtonLabel = sending ? ui.sending : !workspace?.apiKeyConfigured ? ui.bindFirst : ui.send; const canSend = isBound && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sending ? ui.sending : !isBound ? ui.bindFirst : ui.send;
const showBindEntry = !isBound;
const showChatStatusHint = isBound && chatLaunchState !== "ready" && Boolean(chatStatusMessage);
const pageTitle = viewMode === "chat" ? ui.chat : viewMode === "skills" ? ui.skills : viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "chat" ? "查看聊天、技能和运行状态。" : viewMode === "skills" ? "查看当前可用技能。" : viewMode === "plugins" ? "查看已集成插件。" : ui.settingsDesc;
useEffect(() => {
if (!infoText) {
return;
}
const timer = window.setTimeout(() => {
setInfoText("");
}, SUCCESS_NOTICE_TIMEOUT_MS);
return () => window.clearTimeout(timer);
}, [infoText]);
async function loadMessages(sessionId: string, canRead: boolean, showError = false) { async function loadMessages(sessionId: string, canRead: boolean, showError = false) {
if (!canRead) { if (!canRead) {
setMessages([]); setMessages([]);
return; return;
} }
try { try {
setMessages(await desktopApi.chat.listMessages(sessionId)); setMessages(await desktopApi.chat.listMessages(sessionId));
} catch (error) { } catch (error) {
...@@ -239,6 +274,7 @@ export default function App() { ...@@ -239,6 +274,7 @@ export default function App() {
async function refresh() { async function refresh() {
setRefreshing(true); setRefreshing(true);
setErrorText(""); setErrorText("");
try { try {
const [nextConfig, initialRuntime, nextCloud, nextTelemetry, nextSystem] = await Promise.all([ const [nextConfig, initialRuntime, nextCloud, nextTelemetry, nextSystem] = await Promise.all([
desktopApi.config.load(), desktopApi.config.load(),
...@@ -297,9 +333,11 @@ export default function App() { ...@@ -297,9 +333,11 @@ export default function App() {
if (workspace?.chatLaunchState !== "starting") { if (workspace?.chatLaunchState !== "starting") {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
void refresh(); void refresh();
}, 2000); }, 2000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [workspace?.chatLaunchState]); }, [workspace?.chatLaunchState]);
...@@ -314,24 +352,57 @@ export default function App() { ...@@ -314,24 +352,57 @@ export default function App() {
delete window.__QJC_SMOKE__; delete window.__QJC_SMOKE__;
return; return;
} }
window.__QJC_SMOKE__ = { usingMockApi: isMockDesktopApi, gatewayStatus, gatewayHealth, runtimeStatus, runtimeCloudStatus, runtimeTelemetry, config, authSession: null, profile: null, credits: null, skills: workspace?.skills ?? [], modelConfig: null, systemSummary, sessions, messages, logs: [], activeSessionId, workspaceSummary: workspace };
window.__QJC_SMOKE__ = {
usingMockApi: isMockDesktopApi,
gatewayStatus,
gatewayHealth,
runtimeStatus,
runtimeCloudStatus,
runtimeTelemetry,
config,
authSession: null,
profile: null,
credits: null,
skills: workspace?.skills ?? [],
modelConfig: null,
systemSummary,
sessions,
messages,
logs: [],
activeSessionId,
workspaceSummary: workspace
};
}, [activeSessionId, config, gatewayHealth, gatewayStatus, messages, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, systemSummary, workspace]); }, [activeSessionId, config, gatewayHealth, gatewayStatus, messages, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, systemSummary, workspace]);
async function saveConfig(nextApiKey?: string) { async function saveConfig(nextApiKey?: string) {
if (!config) { if (!config) {
return; return;
} }
setSaving(true); setSaving(true);
setErrorText(""); setErrorText("");
setInfoText(""); setInfoText("");
try { try {
const trimmedApiKey = nextApiKey?.trim(); const trimmedApiKey = nextApiKey?.trim();
const input: SaveConfigInput = { provider: config.provider, baseUrl: config.baseUrl, defaultModel: config.defaultModel, workspacePath: workspacePathDraft.trim() || config.workspacePath, gatewayUrl: config.gatewayUrl, cloudApiBaseUrl: config.cloudApiBaseUrl, runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl, runtimeMode: "bundled-runtime", ...(trimmedApiKey ? { apiKey: trimmedApiKey } : {}) }; const input: SaveConfigInput = {
provider: config.provider,
baseUrl: config.baseUrl,
defaultModel: config.defaultModel,
workspacePath: workspacePathDraft.trim() || config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl,
runtimeMode: "bundled-runtime",
...(trimmedApiKey ? { apiKey: trimmedApiKey } : {})
};
const savedConfig = await desktopApi.config.save(input); const savedConfig = await desktopApi.config.save(input);
setConfig(savedConfig); setConfig(savedConfig);
setWorkspacePathDraft(savedConfig.workspacePath); setWorkspacePathDraft(savedConfig.workspacePath);
setApiKeyDraft(""); setApiKeyDraft("");
setInfoText(trimmedApiKey ? "员工密钥已保存。" : "已清除员工密钥。" ); setInfoText(trimmedApiKey ? "员工密钥已保存。" : "已清除员工密钥。");
await refresh(); await refresh();
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
...@@ -398,12 +469,15 @@ export default function App() { ...@@ -398,12 +469,15 @@ export default function App() {
if (!canSend) { if (!canSend) {
return; return;
} }
setSending(true); setSending(true);
setErrorText(""); setErrorText("");
try { try {
await ensureChatAvailable(); await ensureChatAvailable();
const skillId = selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id; const skillId = selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id;
const result = await desktopApi.chat.sendPrompt(DEFAULT_SESSION_ID, prompt.trim(), skillId); const result = await desktopApi.chat.sendPrompt(DEFAULT_SESSION_ID, prompt.trim(), skillId);
setPrompt(""); setPrompt("");
setActiveSessionId(result.sessionId); setActiveSessionId(result.sessionId);
await loadMessages(result.sessionId, true, true); await loadMessages(result.sessionId, true, true);
...@@ -430,6 +504,7 @@ export default function App() { ...@@ -430,6 +504,7 @@ export default function App() {
async function exportDiagnostics() { async function exportDiagnostics() {
setErrorText(""); setErrorText("");
setInfoText(""); setInfoText("");
try { try {
const result = await desktopApi.diagnostics.exportSnapshot(); const result = await desktopApi.diagnostics.exportSnapshot();
setInfoText(ui.exported + result.filePath); setInfoText(ui.exported + result.filePath);
...@@ -438,9 +513,6 @@ export default function App() { ...@@ -438,9 +513,6 @@ export default function App() {
} }
} }
const pageTitle = viewMode === "chat" ? ui.chat : viewMode === "skills" ? ui.skills : viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "chat" ? "查看聊天、技能和运行状态。" : viewMode === "skills" ? "查看当前可用技能。" : viewMode === "plugins" ? "查看已集成插件。" : ui.settingsDesc;
return ( return (
<div className="shell"> <div className="shell">
<aside className="sidebar"> <aside className="sidebar">
...@@ -456,48 +528,72 @@ export default function App() { ...@@ -456,48 +528,72 @@ export default function App() {
</nav> </nav>
</aside> </aside>
<div className="main-shell"> <div className="main-shell">
<header className="page-header panel"> <div className="page-topbar">
<div><h2>{pageTitle}</h2><p>{pageDesc}</p></div> {viewMode === "chat" ? (
<div className="header-actions">{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}<button className="secondary" disabled={refreshing || saving} onClick={() => void refresh()}>{refreshing ? ui.preparing : ui.refresh}</button></div> <p className="hero-line">{ui.heroLine}</p>
</header> ) : (
{infoText ? <div className="notice">{infoText}</div> : null} <div className="page-copy">
<h2>{pageTitle}</h2>
<p>{pageDesc}</p>
</div>
)}
<div className="header-actions">
{viewMode === "chat" && isBound ? <StatusChip tone="positive">{ui.bound}</StatusChip> : null}
{isMockDesktopApi ? <StatusChip tone="warning">Mock API</StatusChip> : null}
</div>
</div>
{infoText ? <div className="notice toast-notice">{infoText}</div> : null}
{errorText ? <div className="notice error">{errorText}</div> : null} {errorText ? <div className="notice error">{errorText}</div> : null}
<main className="content-area"> <main className="content-area">
{viewMode === "chat" ? ( {viewMode === "chat" ? (
<section className="panel chat-panel"> <section className="panel chat-panel">
<div className="chat-topbar"> {showBindEntry ? (
<div className="bind-block"> <div className="bind-entry">
<div className="section-head compact"> <div className="bind-entry-copy">
<div><h3>{ui.bindTitle}</h3><p>{ui.bindDesc}</p></div> <strong>{ui.bindTitle}</strong>
<StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip> <p>{ui.bindDesc}</p>
</div> </div>
{workspace?.apiKeyConfigured ? ( <div className="bind-row">
<> <input type="password" value={apiKeyDraft} placeholder={ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} />
<div className="mini-info"><span>{ui.currentBinding}</span><strong>{ui.bound}</strong></div> <button disabled={saving || apiKeyDraft.trim().length === 0} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.binding : ui.bindNow}</button>
<div className="inline-hint">{ui.bindingManagedInSettings}</div> </div>
</> </div>
) : ( ) : null}
<div className="bind-row"> {showChatStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{chatStatusMessage}</div> : null}
<input type="password" value={apiKeyDraft} placeholder={ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /> <div className="message-list">
<button disabled={saving || apiKeyDraft.trim().length === 0} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.binding : ui.bindNow}</button> {messages.map((message) => (
</div> <article key={message.id} className={"message-card " + message.role}>
)} <header><strong>{message.role === "assistant" ? ui.app : message.role === "user" ? "用户" : "系统"}</strong></header>
<p>{message.content}</p>
</article>
))}
{!messages.length ? <div className="empty-state">{ui.noMessages}</div> : null}
</div>
<div className="composer-shell">
<div className="composer-meta">
<label className="skill-select">
<span className="field-label">{ui.skillChoice}</span>
<select value={selectedSkillId} disabled={!isBound} onChange={(event) => setSelectedSkillId(event.target.value)}>
{effectiveSkills.map((skill) => <option key={skill.id} value={skill.id}>{skill.name}</option>)}
</select>
</label>
<p className="composer-hint">{isBound ? selectedSkill.description : ui.taskDisabledPlaceholder}</p>
</div> </div>
<div className="skill-block"> <label>
<label>{ui.skillChoice}<select value={selectedSkillId} onChange={(event) => setSelectedSkillId(event.target.value)}>{effectiveSkills.map((skill) => <option key={skill.id} value={skill.id}>{skill.name}</option>)}</select></label> <span className="field-label">{selectedSkill.name}</span>
<textarea value={prompt} disabled={!isBound} onChange={(event) => setPrompt(event.target.value)} placeholder={isBound ? ui.taskPlaceholder : ui.taskDisabledPlaceholder} />
</label>
<div className="button-row composer-actions">
<button disabled={!canSend} onClick={() => void sendPrompt()}>{sendButtonLabel}</button>
</div> </div>
</div> </div>
{workspace?.apiKeyConfigured && chatLaunchState !== "ready" && chatStatusMessage ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{chatStatusMessage}</div> : null}
<div className="message-list">{messages.map((message) => <article key={message.id} className={"message-card " + message.role}><header><strong>{message.role === "assistant" ? ui.app : message.role === "user" ? "用户" : "系统"}</strong></header><p>{message.content}</p></article>)}{!messages.length ? <div className="empty-state">{ui.noMessages}</div> : null}</div>
<label><span className="field-label">{selectedSkill.name}</span><textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder={ui.taskPlaceholder} /></label>
<div className="button-row"><button disabled={!canSend} onClick={() => void sendPrompt()}>{sendButtonLabel}</button><button className="secondary" disabled={refreshing || saving} onClick={() => void refresh()}>{ui.refresh}</button></div>
</section> </section>
) : null} ) : null}
{viewMode === "skills" ? <section className="panel catalog-list">{effectiveSkills.map((skill) => <button key={skill.id} type="button" className="catalog-item" onClick={() => { setSelectedSkillId(skill.id); setViewMode("chat"); }}><strong>{skill.name}</strong><p>{skill.description}</p></button>)}{!effectiveSkills.length ? <div className="empty-state">{ui.noSkillCards}</div> : null}</section> : null} {viewMode === "skills" ? <section className="panel catalog-list"><div className="scroll-panel">{effectiveSkills.map((skill) => <button key={skill.id} type="button" className="catalog-item" onClick={() => { setSelectedSkillId(skill.id); setViewMode("chat"); }}><strong>{skill.name}</strong><p>{skill.description}</p></button>)}{!effectiveSkills.length ? <div className="empty-state">{ui.noSkillCards}</div> : null}</div></section> : null}
{viewMode === "plugins" ? <section className="panel catalog-list"><div className="section-head compact"><div><h3>{ui.pluginTitle}</h3></div></div>{workspace?.plugins.map((plugin) => { const copy = getPluginCopy(plugin); return <article key={plugin.id} className="catalog-item static"><strong>{copy.name}</strong><p>{copy.description}</p></article>; })}{!workspace?.plugins.length ? <div className="empty-state">{ui.noPlugins}</div> : null}</section> : null} {viewMode === "plugins" ? <section className="panel catalog-list"><div className="section-head compact"><div><h3>{ui.pluginTitle}</h3></div></div><div className="scroll-panel">{workspace?.plugins.map((plugin) => { const copy = getPluginCopy(plugin); return <article key={plugin.id} className="catalog-item static"><strong>{copy.name}</strong><p>{copy.description}</p></article>; })}{!workspace?.plugins.length ? <div className="empty-state">{ui.noPlugins}</div> : null}</div></section> : null}
{viewMode === "settings" ? <div className="page-stack"><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.settingsTitle}</h3><p>{ui.settingsDesc}</p></div><StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip></div><div className="form-grid single"><label>{ui.apiKey}<input type="password" value={apiKeyDraft} placeholder={workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /></label><label>{ui.workspacePath}<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /></label></div><div className="button-row"><button disabled={saving} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.saving : ui.save}</button></div><div className="mini-info"><span>{ui.currentBinding}</span><strong>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</strong></div></section><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.diagnostics}</h3><p>{ui.diagnosticsDesc}</p></div></div><div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div><div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div></section></div> : null} {viewMode === "settings" ? <div className="page-stack"><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.settingsTitle}</h3><p>{ui.settingsDesc}</p></div><StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip></div><div className="form-grid single"><label>{ui.apiKey}<input type="password" value={apiKeyDraft} placeholder={workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /></label><label>{ui.workspacePath}<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /></label></div><div className="button-row"><button disabled={saving} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.saving : ui.save}</button></div><div className="mini-info"><span>{ui.currentBinding}</span><strong>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</strong></div></section><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.diagnostics}</h3><p>{ui.diagnosticsDesc}</p></div></div><div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div><div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div></section></div> : null}
</main> </main>
</div> </div>
</div> </div>
); );
} }
\ No newline at end of file
...@@ -8,8 +8,14 @@ ...@@ -8,8 +8,14 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { margin: 0; min-height: 100%; } html, body, #root {
body { min-height: 100vh; } margin: 0;
height: 100%;
}
body {
min-height: 100vh;
overflow: hidden;
}
button, input, textarea, select { font: inherit; } button, input, textarea, select { font: inherit; }
button { button {
border: 0; border: 0;
...@@ -25,7 +31,13 @@ button.secondary { ...@@ -25,7 +31,13 @@ button.secondary {
color: #2d3955; color: #2d3955;
box-shadow: inset 0 0 0 1px #d8e1ef; box-shadow: inset 0 0 0 1px #d8e1ef;
} }
button:disabled { opacity: 0.55; cursor: not-allowed; } button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
input, textarea, select { input, textarea, select {
width: 100%; width: 100%;
border: 1px solid #d9e2ef; border: 1px solid #d9e2ef;
...@@ -34,27 +46,52 @@ input, textarea, select { ...@@ -34,27 +46,52 @@ input, textarea, select {
background: #fff; background: #fff;
color: #182236; color: #182236;
} }
textarea { min-height: 150px; resize: vertical; } textarea {
label { display: grid; gap: 8px; color: #53637f; font-size: 13px; } min-height: 128px;
resize: vertical;
max-height: 220px;
overflow: auto;
overscroll-behavior: contain;
}
label {
display: grid;
gap: 8px;
color: #53637f;
font-size: 13px;
}
p, h1, h2, h3, strong, span { margin: 0; } p, h1, h2, h3, strong, span { margin: 0; }
strong { font-weight: 600; } strong { font-weight: 600; }
.shell { .shell {
height: 100vh;
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 208px minmax(0, 1fr); grid-template-columns: 208px minmax(0, 1fr);
overflow: hidden;
} }
.sidebar { .sidebar {
height: 100vh;
padding: 22px 16px; padding: 22px 16px;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto auto;
align-content: start;
gap: 14px; gap: 14px;
background: linear-gradient(180deg, #f8fbff, #edf3fa); background: linear-gradient(180deg, #f8fbff, #edf3fa);
border-right: 1px solid #dee6f1; border-right: 1px solid #dee6f1;
} }
.brand-block, .nav-list, .page-stack, .content-area, .message-list, .catalog-list, .form-grid, .skill-picker, .chat-panel, .settings-panel, .bind-block { .brand-block,
.nav-list,
.page-stack,
.content-area,
.catalog-list,
.form-grid,
.chat-panel,
.settings-panel,
.bind-entry,
.composer-shell,
.scroll-panel {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
...@@ -71,13 +108,25 @@ strong { font-weight: 600; } ...@@ -71,13 +108,25 @@ strong { font-weight: 600; }
} }
.brand-block h1 { font-size: 28px; } .brand-block h1 { font-size: 28px; }
.brand-block p, .page-header p, .section-head p, .catalog-item p, .notice, .empty-state, .mini-info span, .inline-hint { .brand-block p,
.page-copy p,
.catalog-item p,
.notice,
.empty-state,
.mini-info span,
.inline-hint,
.bind-entry-copy p,
.composer-hint {
color: #667794; color: #667794;
line-height: 1.6; line-height: 1.6;
font-size: 13px; font-size: 13px;
} }
.nav-list { gap: 8px; } .nav-list {
gap: 8px;
align-content: start;
align-self: start;
}
.nav-item { .nav-item {
height: 42px; height: 42px;
padding: 0 12px; padding: 0 12px;
...@@ -97,49 +146,102 @@ strong { font-weight: 600; } ...@@ -97,49 +146,102 @@ strong { font-weight: 600; }
} }
.main-shell { .main-shell {
height: 100vh;
min-height: 0;
padding: 22px; padding: 22px;
display: grid; display: flex;
flex-direction: column;
gap: 14px; gap: 14px;
overflow: hidden;
} }
.panel, .notice, .empty-state, .message-card, .catalog-item { .panel,
.notice,
.empty-state,
.message-card,
.catalog-item {
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid #dfe7f2; border: 1px solid #dfe7f2;
box-shadow: 0 18px 40px rgba(35, 52, 82, 0.06); box-shadow: 0 18px 40px rgba(35, 52, 82, 0.06);
} }
.panel { padding: 20px; } .panel { padding: 18px; }
.page-header {
padding: 16px 20px; .page-topbar,
.header-actions,
.section-head,
.button-row,
.mini-info,
.bind-row,
.composer-meta {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 12px;
} }
.header-actions, .section-head, .button-row, .mini-info, .bind-row { .page-topbar {
display: flex; align-items: flex-start;
align-items: center; flex: 0 0 auto;
justify-content: space-between; }
gap: 10px;
.page-copy {
display: grid;
gap: 4px;
}
.hero-line {
font-size: 24px;
line-height: 1.35;
letter-spacing: 0.01em;
color: #182236;
} }
.section-head.compact { align-items: flex-start; } .section-head.compact { align-items: flex-start; }
.notice, .empty-state, .catalog-item { padding: 14px; } .notice, .empty-state, .catalog-item { padding: 14px; }
.notice { background: rgba(15, 123, 255, 0.08); color: #28507f; } .notice {
.notice.error { background: rgba(239, 68, 68, 0.08); color: #972f2f; } background: rgba(15, 123, 255, 0.08);
color: #28507f;
flex: 0 0 auto;
}
.notice.error {
background: rgba(239, 68, 68, 0.08);
color: #972f2f;
}
.toast-notice {
animation: notice-fade 2.4s ease forwards;
}
.empty-state { background: #f8fbff; border-style: dashed; } .empty-state { background: #f8fbff; border-style: dashed; }
.chat-topbar { .content-area {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.chat-panel {
height: 100%;
min-height: 0;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
}
.bind-entry {
padding: 14px;
border-radius: 16px;
border: 1px dashed #cfdced;
background: #f8fbff;
}
.bind-entry-copy {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(220px, 0.9fr); gap: 6px;
gap: 14px;
align-items: start;
} }
.bind-row input { flex: 1 1 auto; } .bind-row input { flex: 1 1 auto; }
.bind-row button { flex: 0 0 auto; min-width: 88px; } .bind-row button { flex: 0 0 auto; min-width: 96px; }
.inline-hint { .inline-hint {
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
...@@ -153,16 +255,60 @@ strong { font-weight: 600; } ...@@ -153,16 +255,60 @@ strong { font-weight: 600; }
} }
.field-label { color: #53637f; font-size: 13px; } .field-label { color: #53637f; font-size: 13px; }
.message-list { .message-list,
min-height: 280px; .scroll-panel,
max-height: 480px; .page-stack {
min-height: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
}
.message-list {
padding-right: 4px; padding-right: 4px;
display: grid;
align-content: start;
gap: 12px;
} }
.scroll-panel {
align-content: start;
}
.message-card { padding: 16px; } .message-card { padding: 16px; }
.message-card.user { background: #eef5ff; } .message-card.user { background: #eef5ff; }
.message-card.assistant { background: #eefbf7; } .message-card.assistant { background: #eefbf7; }
.message-card p { white-space: pre-wrap; line-height: 1.7; } .message-card p {
white-space: pre-wrap;
line-height: 1.7;
margin-top: 6px;
}
.composer-shell {
gap: 10px;
padding: 14px;
border-radius: 16px;
border: 1px solid #dbe5f1;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(255, 255, 255, 0.98));
flex: 0 0 auto;
}
.composer-meta {
align-items: flex-end;
}
.skill-select {
min-width: 220px;
max-width: 300px;
}
.composer-hint {
flex: 1 1 auto;
min-width: 0;
}
.composer-actions {
justify-content: flex-end;
}
.catalog-item { .catalog-item {
text-align: left; text-align: left;
...@@ -198,21 +344,58 @@ strong { font-weight: 600; } ...@@ -198,21 +344,58 @@ strong { font-weight: 600; }
.status-chip.positive { background: rgba(16, 185, 129, 0.12); color: #0f7f59; } .status-chip.positive { background: rgba(16, 185, 129, 0.12); color: #0f7f59; }
.status-chip.warning { background: rgba(245, 158, 11, 0.14); color: #b46f0a; } .status-chip.warning { background: rgba(245, 158, 11, 0.14); color: #b46f0a; }
@keyframes notice-fade {
0%, 78% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
@media (max-width: 1100px) {
.hero-line { font-size: 21px; }
.composer-meta {
align-items: stretch;
flex-direction: column;
}
.skill-select {
min-width: 0;
max-width: none;
width: 100%;
}
}
@media (max-width: 960px) { @media (max-width: 960px) {
.shell { grid-template-columns: 1fr; } .shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.sidebar { .sidebar {
height: auto;
border-right: 0; border-right: 0;
border-bottom: 1px solid #dee6f1; border-bottom: 1px solid #dee6f1;
} }
.main-shell {
height: 100%;
}
.nav-list { grid-template-columns: repeat(4, minmax(0, 1fr)); } .nav-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.chat-topbar { grid-template-columns: 1fr; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.main-shell, .sidebar { padding: 16px; } .main-shell, .sidebar { padding: 16px; }
.page-header, .header-actions, .button-row, .mini-info, .bind-row { align-items: stretch; flex-direction: column; } .page-topbar,
.header-actions,
.button-row,
.mini-info,
.bind-row {
align-items: stretch;
flex-direction: column;
}
.nav-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .nav-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.nav-item { justify-content: center; } .nav-item { justify-content: center; }
} .hero-line { font-size: 18px; }
.chat-panel { padding: 16px; }
}
\ No newline at end of file
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