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";
function resolveRendererEntry(): string {
......@@ -14,8 +14,8 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
const window = new BrowserWindow({
width: 1400,
height: 920,
minWidth: 1080,
minHeight: 720,
minWidth: 960,
minHeight: 640,
backgroundColor: "#0f172a",
webPreferences: {
additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [],
......@@ -35,3 +35,4 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
return window;
}
......@@ -21,6 +21,7 @@ type ViewMode = "chat" | "skills" | "plugins" | "settings";
type Tone = "positive" | "warning";
const DEFAULT_SESSION_ID = "desktop-main";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const DEFAULT_SKILL = {
id: "default-chat",
name: "默认对话",
......@@ -30,16 +31,16 @@ const DEFAULT_SKILL = {
};
const ui = {
app: "钱江爪",
app: "千匠Claw",
subtitle: "OpenClaw Client",
appDesc: "绑定 api_key 后自动配置运行时",
appDesc: "绑定 api_key 后自动拉取运行时配置",
heroLine: "千匠Claw,您身边最得力的员工,Start Your Ideas....",
chat: "对话",
skills: "技能",
plugins: "插件",
settings: "设置",
bound: "已绑定",
unbound: "未绑定",
preparing: "准备中",
defaultChat: "默认对话",
bindTitle: "绑定员工密钥",
bindDesc: "输入 OpenClaw employee api_key 后,桌面端会自动拉取运行时配置。",
......@@ -48,17 +49,16 @@ const ui = {
bindNow: "立即绑定",
binding: "绑定中...",
changeApiKey: "更换员工密钥",
bindingManagedInSettings: "员工密钥已在设置中管理",
skillChoice: "选择技能",
noMessages: "当前没有消息,请先发送一条消息。",
taskPlaceholder: "输入消息后回车或点击发送",
taskDisabledPlaceholder: "请先绑定员工密钥后开始对话。",
send: "发送",
sending: "发送中...",
bindFirst: "请先绑定",
bindFirstError: "请先绑定员工密钥后再发送消息。",
refresh: "刷新",
startingHint: "运行时正在启动,请稍候。",
chatNotReadyError: "当前聊天不可用,请检查运行时状态。",
chatNotReadyError: "当前聊天不可用,请检查运行时状态。",
noSkillCards: "当前没有可用技能。",
pluginTitle: "插件列表",
noPlugins: "当前没有可用插件。",
......@@ -86,7 +86,7 @@ const pluginDisplayMap: Record<string, { name: string; description: string }> =
"runtime-diagnostics": { name: "运行时诊断", description: "查看运行时信息、日志和状态。" },
"browser-automation": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"browser-plugin": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"ocr-tools": { name: "OCR 识别", description: "识别扫描件和图片文字并提取结。" }
"ocr-tools": { name: "OCR 识别", description: "识别扫描件和图片文字并提取结。" }
};
const mockDesktopApi = {
......@@ -166,12 +166,32 @@ const smokeEnabled = window.qjcSmokeEnabled === true;
declare global {
interface Window {
qjcDesktop?: DesktopApi;
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 }) {
return <span className={"status-chip " + tone}>{children}</span>;
......@@ -210,22 +230,37 @@ export default function App() {
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const effectiveSkills = useMemo(() => {
return workspace?.skills?.length ? workspace.skills : [DEFAULT_SKILL];
}, [workspace]);
const effectiveSkills = useMemo(() => (workspace?.skills?.length ? workspace.skills : [DEFAULT_SKILL]), [workspace]);
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 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 canSend = Boolean(workspace?.apiKeyConfigured) && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sending ? ui.sending : !workspace?.apiKeyConfigured ? ui.bindFirst : ui.send;
const isBound = Boolean(workspace?.apiKeyConfigured);
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) {
if (!canRead) {
setMessages([]);
return;
}
try {
setMessages(await desktopApi.chat.listMessages(sessionId));
} catch (error) {
......@@ -239,6 +274,7 @@ export default function App() {
async function refresh() {
setRefreshing(true);
setErrorText("");
try {
const [nextConfig, initialRuntime, nextCloud, nextTelemetry, nextSystem] = await Promise.all([
desktopApi.config.load(),
......@@ -297,9 +333,11 @@ export default function App() {
if (workspace?.chatLaunchState !== "starting") {
return;
}
const timer = window.setTimeout(() => {
void refresh();
}, 2000);
return () => window.clearTimeout(timer);
}, [workspace?.chatLaunchState]);
......@@ -314,24 +352,57 @@ export default function App() {
delete window.__QJC_SMOKE__;
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]);
async function saveConfig(nextApiKey?: string) {
if (!config) {
return;
}
setSaving(true);
setErrorText("");
setInfoText("");
try {
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);
setConfig(savedConfig);
setWorkspacePathDraft(savedConfig.workspacePath);
setApiKeyDraft("");
setInfoText(trimmedApiKey ? "员工密钥已保存。" : "已清除员工密钥。" );
setInfoText(trimmedApiKey ? "员工密钥已保存。" : "已清除员工密钥。");
await refresh();
} catch (error) {
setErrorText(err(error));
......@@ -398,12 +469,15 @@ export default function App() {
if (!canSend) {
return;
}
setSending(true);
setErrorText("");
try {
await ensureChatAvailable();
const skillId = selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id;
const result = await desktopApi.chat.sendPrompt(DEFAULT_SESSION_ID, prompt.trim(), skillId);
setPrompt("");
setActiveSessionId(result.sessionId);
await loadMessages(result.sessionId, true, true);
......@@ -430,6 +504,7 @@ export default function App() {
async function exportDiagnostics() {
setErrorText("");
setInfoText("");
try {
const result = await desktopApi.diagnostics.exportSnapshot();
setInfoText(ui.exported + result.filePath);
......@@ -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 (
<div className="shell">
<aside className="sidebar">
......@@ -456,45 +528,69 @@ export default function App() {
</nav>
</aside>
<div className="main-shell">
<header className="page-header panel">
<div><h2>{pageTitle}</h2><p>{pageDesc}</p></div>
<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>
</header>
{infoText ? <div className="notice">{infoText}</div> : null}
<div className="page-topbar">
{viewMode === "chat" ? (
<p className="hero-line">{ui.heroLine}</p>
) : (
<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}
<main className="content-area">
{viewMode === "chat" ? (
<section className="panel chat-panel">
<div className="chat-topbar">
<div className="bind-block">
<div className="section-head compact">
<div><h3>{ui.bindTitle}</h3><p>{ui.bindDesc}</p></div>
<StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip>
{showBindEntry ? (
<div className="bind-entry">
<div className="bind-entry-copy">
<strong>{ui.bindTitle}</strong>
<p>{ui.bindDesc}</p>
</div>
{workspace?.apiKeyConfigured ? (
<>
<div className="mini-info"><span>{ui.currentBinding}</span><strong>{ui.bound}</strong></div>
<div className="inline-hint">{ui.bindingManagedInSettings}</div>
</>
) : (
<div className="bind-row">
<input type="password" value={apiKeyDraft} placeholder={ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} />
<button disabled={saving || apiKeyDraft.trim().length === 0} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.binding : ui.bindNow}</button>
</div>
)}
</div>
<div className="skill-block">
<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>
) : null}
{showChatStatusHint ? <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>
<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>
<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>
{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>
) : 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 === "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 === "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><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}
</main>
</div>
......
......@@ -8,8 +8,14 @@
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; min-height: 100%; }
body { min-height: 100vh; }
html, body, #root {
margin: 0;
height: 100%;
}
body {
min-height: 100vh;
overflow: hidden;
}
button, input, textarea, select { font: inherit; }
button {
border: 0;
......@@ -25,7 +31,13 @@ button.secondary {
color: #2d3955;
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 {
width: 100%;
border: 1px solid #d9e2ef;
......@@ -34,27 +46,52 @@ input, textarea, select {
background: #fff;
color: #182236;
}
textarea { min-height: 150px; resize: vertical; }
label { display: grid; gap: 8px; color: #53637f; font-size: 13px; }
textarea {
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; }
strong { font-weight: 600; }
.shell {
height: 100vh;
min-height: 100vh;
display: grid;
grid-template-columns: 208px minmax(0, 1fr);
overflow: hidden;
}
.sidebar {
height: 100vh;
padding: 22px 16px;
display: grid;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto;
align-content: start;
gap: 14px;
background: linear-gradient(180deg, #f8fbff, #edf3fa);
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;
gap: 12px;
}
......@@ -71,13 +108,25 @@ strong { font-weight: 600; }
}
.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;
line-height: 1.6;
font-size: 13px;
}
.nav-list { gap: 8px; }
.nav-list {
gap: 8px;
align-content: start;
align-self: start;
}
.nav-item {
height: 42px;
padding: 0 12px;
......@@ -97,49 +146,102 @@ strong { font-weight: 600; }
}
.main-shell {
height: 100vh;
min-height: 0;
padding: 22px;
display: grid;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.panel, .notice, .empty-state, .message-card, .catalog-item {
.panel,
.notice,
.empty-state,
.message-card,
.catalog-item {
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #dfe7f2;
box-shadow: 0 18px 40px rgba(35, 52, 82, 0.06);
}
.panel { padding: 20px; }
.page-header {
padding: 16px 20px;
.panel { padding: 18px; }
.page-topbar,
.header-actions,
.section-head,
.button-row,
.mini-info,
.bind-row,
.composer-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
}
.header-actions, .section-head, .button-row, .mini-info, .bind-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.page-topbar {
align-items: flex-start;
flex: 0 0 auto;
}
.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; }
.notice, .empty-state, .catalog-item { padding: 14px; }
.notice { background: rgba(15, 123, 255, 0.08); color: #28507f; }
.notice.error { background: rgba(239, 68, 68, 0.08); color: #972f2f; }
.notice {
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; }
.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;
grid-template-columns: minmax(0, 1.6fr) minmax(220px, 0.9fr);
gap: 14px;
align-items: start;
gap: 6px;
}
.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 {
padding: 10px 12px;
border-radius: 12px;
......@@ -153,16 +255,60 @@ strong { font-weight: 600; }
}
.field-label { color: #53637f; font-size: 13px; }
.message-list {
min-height: 280px;
max-height: 480px;
.message-list,
.scroll-panel,
.page-stack {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.message-list {
padding-right: 4px;
display: grid;
align-content: start;
gap: 12px;
}
.scroll-panel {
align-content: start;
}
.message-card { padding: 16px; }
.message-card.user { background: #eef5ff; }
.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 {
text-align: left;
......@@ -198,21 +344,58 @@ strong { font-weight: 600; }
.status-chip.positive { background: rgba(16, 185, 129, 0.12); color: #0f7f59; }
.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) {
.shell { grid-template-columns: 1fr; }
.shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.sidebar {
height: auto;
border-right: 0;
border-bottom: 1px solid #dee6f1;
}
.main-shell {
height: 100%;
}
.nav-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.chat-topbar { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.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-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