fix(hololake): ship dynamic persona routes and coherent workspace

This commit is contained in:
冰朔 2026-08-09 10:43:56 +08:00
commit 787ba2ad3f
21 changed files with 1111 additions and 326 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
import { api, DocTreeNode, DocContent } from './api';
import { DocTree } from './components/DocTree';
import { Editor } from './components/Editor';
@ -80,6 +80,8 @@ export default function App() {
const [humanSettingsOpen, setHumanSettingsOpen] = useState(false);
const [humanPreferences, setHumanPreferences] = useState<HumanPreferences>(() => loadHumanPreferences());
const [agentRevision, setAgentRevision] = useState(0);
const [treeWidth, setTreeWidth] = useState(() => Number(localStorage.getItem('hololake.layout.tree-width')) || 258);
const [agentWidth, setAgentWidth] = useState(() => Number(localStorage.getItem('hololake.layout.agent-width')) || 460);
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
@ -113,6 +115,23 @@ export default function App() {
}
}, []);
const openWikiTarget = useCallback(async (rawTarget: string) => {
const target = rawTarget.trim().replace(/^\/+/, '');
const normalized = target.toLocaleLowerCase().replace(/\.md$/u, '');
const flatten = (nodes: DocTreeNode[]): DocTreeNode[] => nodes.flatMap(node => [node, ...(node.children ? flatten(node.children) : [])]);
const match = flatten(tree).find(node => {
if (node.type !== 'document') return false;
const path = node.path.toLocaleLowerCase().replace(/\.md$/u, '');
const name = node.name.toLocaleLowerCase().replace(/\.md$/u, '');
return path === normalized || name === normalized || path.endsWith(`/${normalized}`);
});
if (match) {
await openDoc(match.path);
return;
}
setError(`没有找到知识页面:${target}`);
}, [openDoc, tree]);
const refreshRepositoryStatus = useCallback(async () => {
try {
const response = await fetch(`${agentApiBase}/api/forgejo/status`);
@ -152,6 +171,10 @@ export default function App() {
document.documentElement.lang = resolvedLanguage;
}, [humanPreferences.language]);
useEffect(() => {
localStorage.setItem('hololake.human-preferences.v1', JSON.stringify(humanPreferences));
}, [humanPreferences]);
useEffect(() => {
if (!treeLoaded || currentPath || loading) return;
const firstDocument = findFirstDocument(tree);
@ -254,6 +277,30 @@ export default function App() {
const channelTitle = personalServer?.channelTitle || '我的第五域';
const channelSubtitle = personalServer?.channelSubtitle || '当前频道';
const startResize = useCallback((kind: 'tree' | 'agent', event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = kind === 'tree' ? treeWidth : agentWidth;
document.body.classList.add('is-resizing');
const move = (moveEvent: PointerEvent) => {
if (kind === 'tree') {
setTreeWidth(Math.max(210, Math.min(420, startWidth + moveEvent.clientX - startX)));
} else {
setAgentWidth(Math.max(360, Math.min(Math.max(360, window.innerWidth * 0.52), startWidth - moveEvent.clientX + startX)));
}
};
const stop = () => {
document.body.classList.remove('is-resizing');
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop, { once: true });
}, [treeWidth, agentWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.tree-width', String(Math.round(treeWidth))); }, [treeWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.agent-width', String(Math.round(agentWidth))); }, [agentWidth]);
return (
<div
className="hololake-shell"
@ -265,6 +312,9 @@ export default function App() {
<div className="topbar-spacer" />
<div className="topbar-channel">{channelTitle} · {channelSubtitle}</div>
<div className="topbar-search"><SearchBar onSelect={openDoc} /></div>
<button className="theme-quick-toggle" type="button" onClick={() => setHumanPreferences(current => ({ ...current, appearance: current.appearance === 'mist-light' ? 'lake-night' : 'mist-light' }))}>
{humanPreferences.appearance === 'mist-light' ? '雾白' : '湖夜'}
</button>
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
@ -282,6 +332,7 @@ export default function App() {
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
onRouteSelect={route => {
setActiveRoute(route);
setActiveModule('knowledge');
setModuleCollapsed(false);
}}
onKnowledgeSelect={() => {
@ -312,7 +363,7 @@ export default function App() {
) : (
<DomainSurface
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
educationSelected={activeModule === 'education'}
educationSelected={activeRoute === 'sub' && activeModule === 'education'}
onOpenSettings={() => setHumanSettingsOpen(true)}
/>
)
@ -345,7 +396,10 @@ export default function App() {
</div>
</div>
<div className="knowledge-layout">
<div
className={`knowledge-workbench ${sidebarOpen ? 'has-tree' : ''} ${agentPanelOpen ? 'has-agent' : ''}`}
style={{ '--tree-width': `${treeWidth}px`, '--agent-width': `${agentWidth}px` } as CSSProperties}
>
{sidebarOpen && (
<aside className="knowledge-sidebar">
<div className="knowledge-sidebar-heading"><span></span><button onClick={() => createDoc('')} aria-label="新建页面"></button></div>
@ -360,6 +414,8 @@ export default function App() {
</aside>
)}
{sidebarOpen && <button type="button" className="workspace-resizer tree-resizer" onPointerDown={event => startResize('tree', event)} aria-label="调整知识树宽度" />}
<main className="knowledge-content">
{error && <div className="kb-error">{error}<button onClick={() => setError(null)}>×</button></div>}
{loading && <div className="kb-loading"></div>}
@ -379,26 +435,31 @@ export default function App() {
{!currentDoc && emptyDismissed && !loading && (
<div className="quiet-empty"><h2></h2><p>使</p><button className="secondary-button" onClick={() => setEmptyDismissed(false)}></button></div>
)}
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} />}
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} onWikiSelect={openWikiTarget} />}
{currentDoc && view === 'history' && <VersionHistory docPath={currentPath} />}
{currentDoc && <div className="document-path">{breadcrumb}</div>}
</main>
{agentPanelOpen && <button type="button" className="workspace-resizer agent-resizer" onPointerDown={event => startResize('agent', event)} aria-label="调整 HoloLake 宽度" />}
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} />
</aside>
)}
</div>
</>
)}
{contentVisible && (
<>
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} runtimeRevision={agentRevision} />
</aside>
)}
<button className={`agent-launcher ${agentPanelOpen ? 'open' : ''}`} onClick={() => setAgentPanelOpen(!agentPanelOpen)}>
{!agentPanelOpen && <button className="agent-launcher" onClick={() => {
if (window.innerWidth < 1500) setSidebarOpen(false);
setAgentPanelOpen(true);
}}>
<span className="launcher-orbit" aria-hidden="true" />
{agentPanelOpen ? '收起 HoloLake' : '询问 HoloLake'}
</button>
HoloLake
</button>}
</>
)}
</section>

View file

@ -1,31 +1,40 @@
/**
* AgentChat HoloLake
*
* Git
*
*/
import { useState, useRef, useEffect } from 'react';
import { marked } from 'marked';
import { useEffect, useRef, useState } from 'react';
import { renderKnowledgeMarkdown } from '../markdown';
interface ToolCall { id: string; name: string; arguments: Record<string, unknown>; }
interface ToolResult { id: string; name: string; output: string; error?: string; }
interface AgentActivity {
id: string;
kind: 'wake' | 'tool' | 'permission' | 'receipt';
label: string;
detail: string;
status: 'running' | 'completed' | 'pending' | 'failed';
timestamp: string;
tool?: string;
}
interface Message {
role: 'user' | 'assistant' | 'tool' | 'system';
role: 'user' | 'assistant' | 'system';
content: string;
toolCalls?: Array<{ id: string; name: string; arguments: Record<string, unknown> }>;
toolResults?: Array<{ id: string; name: string; output: string; error?: string }>;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
activities?: AgentActivity[];
timestamp: string;
}
interface ConversationSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
}
interface AgentStatus {
name: string;
role: string;
model: string;
conversationLength: number;
configured: boolean;
operational: boolean;
tools: string[];
}
interface PendingAction {
id: string;
tool: string;
@ -34,15 +43,17 @@ interface PendingAction {
summary: string;
createdAt: string;
}
interface Props {
apiBase: string;
onDocSelect?: (path: string) => void;
runtimeRevision?: number;
onClose?: () => void;
}
export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }: Props) {
export default function AgentChat({ apiBase, runtimeRevision = 0, onClose }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [status, setStatus] = useState<AgentStatus | null>(null);
@ -52,82 +63,96 @@ export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }:
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
fetchStatus();
}, [runtimeRevision]);
useEffect(() => { void bootstrap(); }, [runtimeRevision]);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, sending]);
useEffect(() => {
if (messages.length > 0 || sending) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, sending]);
async function fetchStatus() {
async function bootstrap() {
try {
const res = await fetch(`${apiBase}/api/agent/status`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.ok) {
setStatusError('');
setStatus({
name: data.persona.name,
role: data.persona.role,
model: data.persona.model,
conversationLength: data.conversationLength,
configured: data.configured,
operational: data.operational,
tools: data.tools || [],
});
}
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (!data.ok) throw new Error(data.error || '无法读取对话');
setConversations(data.conversations || []);
const selected = activeConversationId || data.activeConversationId || data.conversations?.[0]?.id;
if (selected) await selectConversation(selected);
} catch {
setStatus(null);
setStatusError('HoloLake 本地服务未启动');
}
}
async function selectConversation(id: string) {
setActiveConversationId(id);
const [historyResponse, statusResponse, actionsResponse] = await Promise.all([
fetch(`${apiBase}/api/agent/conversation?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/status?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/actions?conversationId=${encodeURIComponent(id)}`),
]);
const [history, runtime, actions] = await Promise.all([historyResponse.json(), statusResponse.json(), actionsResponse.json()]);
if (history.ok) setMessages(history.messages || []);
if (actions.ok) setPendingActions(actions.actions || []);
if (runtime.ok) {
setStatusError('');
setStatus({
name: runtime.persona.name,
role: runtime.persona.role,
model: runtime.persona.model,
configured: runtime.configured,
operational: runtime.operational,
tools: runtime.tools || [],
});
}
}
async function refreshConversationList() {
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (data.ok) setConversations(data.conversations || []);
}
async function createConversation() {
const response = await fetch(`${apiBase}/api/agent/conversations`, { method: 'POST' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.conversation.id);
setHistoryOpen(false);
inputRef.current?.focus();
}
async function deleteConversation(id: string) {
if (!confirm('删除这条对话记录?知识库文档不会被删除。')) return;
const response = await fetch(`${apiBase}/api/agent/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.activeConversationId);
}
async function sendMessage() {
const text = input.trim();
if (!text || sending) return;
if (!text || sending || !activeConversationId) return;
setInput('');
setSending(true);
// 本地先显示用户消息
const userMsg: Message = {
role: 'user',
content: text,
timestamp: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
setMessages(previous => [...previous, { role: 'user', content: text, timestamp: new Date().toISOString() }]);
try {
const res = await fetch(`${apiBase}/api/agent/chat`, {
const response = await fetch(`${apiBase}/api/agent/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
body: JSON.stringify({ message: text, conversationId: activeConversationId }),
});
const data = await res.json();
if (data.ok) {
const assistantMsg: Message = {
role: 'assistant',
content: data.reply,
timestamp: new Date().toISOString(),
};
setMessages(prev => [...prev, assistantMsg]);
setPendingActions(data.pendingActions || []);
fetchStatus(); // 刷新状态
} else {
setMessages(prev => [
...prev,
{ role: 'assistant', content: `错误: ${data.error}`, timestamp: new Date().toISOString() },
]);
}
} catch (err: any) {
setMessages(prev => [
...prev,
{ role: 'assistant', content: `网络错误: ${err.message}`, timestamp: new Date().toISOString() },
]);
const data = await response.json();
if (!data.ok) throw new Error(data.error || '调用失败');
setMessages(previous => [...previous, {
role: 'assistant',
content: data.reply,
toolCalls: data.toolCalls || [],
toolResults: data.toolResults || [],
activities: data.activities || [],
timestamp: new Date().toISOString(),
}]);
setPendingActions(data.pendingActions || []);
await refreshConversationList();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `错误:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setSending(false);
inputRef.current?.focus();
@ -137,188 +162,84 @@ export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }:
async function resolveAction(action: PendingAction, decision: 'confirm' | 'reject') {
setActionBusy(action.id);
try {
const res = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
const response = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId: activeConversationId }),
});
const data = await res.json();
const data = await response.json();
if (!data.ok) throw new Error(data.error || '动作处理失败');
setPendingActions(data.actions || []);
const content = decision === 'confirm'
? (data.result?.error ? `执行失败:${data.result.error}` : `已确认并执行:${data.result?.output || action.target}`)
: `已取消:${action.summary}`;
setMessages(prev => [...prev, { role: 'system', content, timestamp: new Date().toISOString() }]);
fetchStatus();
} catch (err: any) {
setMessages(prev => [...prev, { role: 'system', content: `动作未执行:${err.message}`, timestamp: new Date().toISOString() }]);
setMessages(data.messages || messages);
await refreshConversationList();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `动作未执行:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setActionBusy('');
}
}
async function clearConversation() {
try {
await fetch(`${apiBase}/api/agent/clear`, { method: 'POST' });
setMessages([]);
fetchStatus();
} catch {}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
return (
<div className="agent-chat">
{/* 头部状态栏 */}
<div className="agent-header">
<div className="agent-info">
<div className="agent-avatar" aria-hidden="true"><span /></div>
<div className="agent-meta">
<h3>{status?.name || 'HoloLake'}</h3>
<span className="agent-role">{status?.role || '语言操作入口'}</span>
</div>
<div className="agent-meta"><h3>{status?.name || 'HoloLake'}</h3><span className="agent-role">{status?.role || '语言操作入口'}</span></div>
</div>
<div className="agent-actions">
<span className="agent-model">{status?.model || 'offline'}</span>
<button className="btn-clear" onClick={clearConversation} title="清空对话">
</button>
<button type="button" onClick={createConversation} title="新建对话"></button>
<button type="button" className={historyOpen ? 'selected' : ''} onClick={() => setHistoryOpen(value => !value)} title="历史记录"></button>
<button type="button" onClick={onClose} title="关闭">×</button>
</div>
</div>
<div className="runtime-status">
<div className="runtime-row">
<span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} />
<span>{statusError || (status?.operational ? '语言操作入口可用' : status?.configured ? '模型已保存,等待连通验证' : '请在设置中配置模型')}</span>
<span className="runtime-tools">{status?.tools?.length || 0} </span>
</div>
<div className="runtime-row"><span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} /><span>{statusError || (status?.operational ? '语言操作入口可用' : '请在设置中验证模型')}</span><span className="runtime-tools">{status?.tools?.length || 0} · {status?.model || 'offline'}</span></div>
</div>
{/* 对话区域 */}
<div className="agent-messages">
{messages.length === 0 && (
<div className="agent-welcome">
<h2> HoloLake</h2>
<p>{statusError || (status?.operational ? '可以检索、整理和编辑当前知识库,并为操作保留版本记录。' : '知识库可以独立使用;模型服务在设置中配置并验证。')}</p>
<p className="welcome-hint"></p>
<div className="welcome-suggestions">
<button onClick={() => { setInput('帮我列出所有文档'); inputRef.current?.focus(); }}>
</button>
<button onClick={() => { setInput('搜索关于协议的内容'); inputRef.current?.focus(); }}>
</button>
<button onClick={() => { setInput('创建一篇新的学习笔记'); inputRef.current?.focus(); }}>
</button>
<div className={`agent-body ${historyOpen ? 'history-open' : ''}`}>
{historyOpen && (
<aside className="agent-history" aria-label="对话历史">
<div className="agent-history-heading"><strong></strong><button type="button" onClick={createConversation}></button></div>
<div className="agent-history-list">
{conversations.map(conversation => (
<div className={`agent-history-item ${conversation.id === activeConversationId ? 'selected' : ''}`} key={conversation.id}>
<button type="button" onClick={() => { void selectConversation(conversation.id); setHistoryOpen(false); }}><strong>{conversation.title}</strong><small>{new Date(conversation.updatedAt).toLocaleString('zh-CN')}</small></button>
<button type="button" className="delete" onClick={() => void deleteConversation(conversation.id)} aria-label={`删除 ${conversation.title}`}>×</button>
</div>
))}
</div>
</div>
</aside>
)}
{messages.map((msg, i) => (
<div key={i} className={`msg msg-${msg.role}`}>
{msg.role === 'user' ? (
<div className="msg-bubble msg-user-bubble">
<p>{msg.content}</p>
</div>
) : (
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content">
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="tool-calls">
{msg.toolCalls.map((tc, j) => (
<div key={j} className="tool-call">
<span className="tool-icon"></span>
<span className="tool-name">{tc.name}</span>
<code className="tool-args">{JSON.stringify(tc.arguments)}</code>
</div>
))}
<div className="agent-conversation">
<div className="agent-messages">
{messages.length === 0 && <div className="agent-welcome"><h2></h2><p>HoloLake 使</p></div>}
{messages.map((message, index) => (
<div key={`${message.timestamp}-${index}`} className={`msg msg-${message.role}`}>
{message.role === 'user' ? <div className="msg-bubble msg-user-bubble"><p>{message.content}</p></div> : (
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content">
{message.activities && message.activities.length > 0 && (
<details className="agent-activity" open>
<summary> · {message.activities.length} </summary>
<ol>{message.activities.map(activity => <li key={activity.id} data-status={activity.status}><span /><div><strong>{activity.label}</strong><small>{activity.detail}</small></div><em>{activity.status === 'completed' ? '完成' : activity.status === 'pending' ? '待确认' : activity.status === 'failed' ? '失败' : '进行中'}</em></li>)}</ol>
</details>
)}
<div className="msg-markdown" dangerouslySetInnerHTML={{ __html: renderKnowledgeMarkdown(message.content) }} />
</div>
)}
{msg.toolResults && msg.toolResults.length > 0 && (
<div className="tool-results">
{msg.toolResults.map((tr, j) => (
<div key={j} className={`tool-result ${tr.error ? 'tool-error' : ''}`}>
<span className="tool-icon">{tr.error ? '失败' : '完成'}</span>
<pre>{tr.error || tr.output}</pre>
</div>
))}
</div>
)}
<div
className="msg-markdown"
dangerouslySetInnerHTML={{
__html: marked.parse(msg.content, { async: false }) as string,
}}
/>
</div>
</div>
)}
</div>
))}
{pendingActions.length > 0 && (
<div className="pending-actions" aria-label="待确认动作">
<div className="pending-actions-heading"></div>
{pendingActions.map(action => (
<div className={`pending-action ${action.effect}`} key={action.id}>
<div>
<strong>{action.effect === 'delete' ? '删除' : '写入'} · {action.target}</strong>
<small>{action.summary}</small>
</div>
<div className="pending-action-buttons">
<button onClick={() => resolveAction(action, 'reject')} disabled={Boolean(actionBusy)}></button>
<button className={action.effect === 'delete' ? 'danger' : 'confirm'} onClick={() => resolveAction(action, 'confirm')} disabled={Boolean(actionBusy)}>
{actionBusy === action.id ? '处理中…' : '确认执行'}
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
{sending && (
<div className="msg msg-assistant">
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
{pendingActions.length > 0 && <div className="pending-actions" aria-label="待确认动作"><div className="pending-actions-heading"></div>{pendingActions.map(action => <div className={`pending-action ${action.effect}`} key={action.id}><div><strong>{action.effect === 'delete' ? '删除' : '写入'} · {action.target}</strong><small>{action.summary}</small></div><div className="pending-action-buttons"><button onClick={() => resolveAction(action, 'reject')} disabled={Boolean(actionBusy)}></button><button className={action.effect === 'delete' ? 'danger' : 'confirm'} onClick={() => resolveAction(action, 'confirm')} disabled={Boolean(actionBusy)}>{actionBusy === action.id ? '处理中…' : '确认执行'}</button></div></div>)}</div>}
{sending && <div className="msg msg-assistant"><div className="msg-bubble msg-assistant-bubble"><div className="msg-avatar" aria-hidden="true"><span /></div><div className="msg-content typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div></div>}
<div ref={bottomRef} />
</div>
)}
<div ref={bottomRef} />
</div>
{/* 输入区 */}
<div className="agent-input-area">
<div className="agent-input-wrapper">
<textarea
ref={inputRef}
className="agent-input"
placeholder="向 HoloLake 发出语言指令…"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={sending || !status?.operational}
/>
<button
className="btn-send"
onClick={sendMessage}
disabled={sending || !input.trim() || !status?.operational}
>
{sending ? '⏳' : '↑'}
</button>
</div>
<div className="agent-hint">
{status?.operational ? 'Enter 发送 · 写操作先确认并由本地 Git 留痕 · 服务器推送独立确认' : '语言操作入口暂不可用;知识库仍可独立使用'}
<div className="agent-input-area"><div className="agent-input-wrapper"><textarea ref={inputRef} className="agent-input" placeholder="向 HoloLake 发出语言指令…" value={input} onChange={event => setInput(event.target.value)} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }} rows={1} disabled={sending || !status?.operational} /><button className="btn-send" onClick={sendMessage} disabled={sending || !input.trim() || !status?.operational}></button></div><div className="agent-hint"> · · </div></div>
</div>
</div>
</div>

View file

@ -1,14 +1,15 @@
import { useState, useEffect, useMemo } from 'react';
import { DocContent } from '../api';
import { marked } from 'marked';
import { cleanDisplayText } from '../presentation';
import { renderKnowledgeMarkdown } from '../markdown';
interface Props {
doc: DocContent;
onSave: (title: string, body: string) => void;
onWikiSelect?: (target: string) => void;
}
export function Editor({ doc, onSave }: Props) {
export function Editor({ doc, onSave, onWikiSelect }: Props) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(doc.meta.title);
const [body, setBody] = useState(doc.body);
@ -22,7 +23,7 @@ export function Editor({ doc, onSave }: Props) {
const rendered = useMemo(() => {
try {
return marked(body, { async: false }) as string;
return renderKnowledgeMarkdown(body);
} catch {
return '<p>渲染失败</p>';
}
@ -40,6 +41,15 @@ export function Editor({ doc, onSave }: Props) {
};
const displayPath = doc.meta.id.split('/').map(cleanDisplayText).join(' / ');
const handleRenderedClick = (event: React.MouseEvent<HTMLDivElement>) => {
const anchor = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[data-hololake-wiki="true"]');
if (!anchor) return;
event.preventDefault();
const prefix = 'hololake://knowledge/';
const rawTarget = anchor.href.startsWith(prefix) ? anchor.href.slice(prefix.length) : anchor.title;
if (rawTarget) onWikiSelect?.(decodeURIComponent(rawTarget));
};
return (
<div className="kb-editor">
<div className="kb-page-symbol" aria-hidden="true"></div>
@ -91,6 +101,7 @@ export function Editor({ doc, onSave }: Props) {
<div className="kb-editor-preview-pane">
<div
className="kb-markdown-render"
onClick={handleRenderedClick}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>
@ -99,6 +110,7 @@ export function Editor({ doc, onSave }: Props) {
<div className="kb-editor-read-pane">
<div
className="kb-markdown-render"
onClick={handleRenderedClick}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>

View file

@ -4,14 +4,14 @@ export interface HumanPreferences {
language: 'system' | 'zh-CN' | 'en';
font: 'system' | 'serif' | 'accessible';
readingSize: number;
appearance: 'eternal-lake' | 'deep-night';
appearance: 'lake-night' | 'mist-light' | 'deep-night';
}
export const DEFAULT_HUMAN_PREFERENCES: HumanPreferences = {
language: 'system',
font: 'system',
readingSize: 17,
appearance: 'eternal-lake',
appearance: 'lake-night',
};
const STORAGE_KEY = 'hololake.human-preferences.v1';
@ -19,7 +19,8 @@ const STORAGE_KEY = 'hololake.human-preferences.v1';
export function loadHumanPreferences(): HumanPreferences {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
return { ...DEFAULT_HUMAN_PREFERENCES, ...stored };
const appearance = stored.appearance === 'eternal-lake' ? 'lake-night' : stored.appearance;
return { ...DEFAULT_HUMAN_PREFERENCES, ...stored, appearance: appearance || DEFAULT_HUMAN_PREFERENCES.appearance };
} catch {
return DEFAULT_HUMAN_PREFERENCES;
}
@ -148,7 +149,8 @@ export function HumanSettings({ open, preferences, onClose, onChange, onManageSe
<section className="settings-section">
<div className="settings-section-copy"><strong></strong><small></small></div>
<select value={draft.appearance} onChange={event => setDraft({ ...draft, appearance: event.target.value as HumanPreferences['appearance'] })}>
<option value="eternal-lake"></option>
<option value="lake-night"></option>
<option value="mist-light"></option>
<option value="deep-night"></option>
</select>
</section>

View file

@ -104,14 +104,18 @@ export function PlatformNavigation({
)}
</div>
{activeRoute === 'sub' && (
<nav className="subdomain-navigation" aria-label="光湖分域行业">
<small> · </small>
<button type="button" onClick={onEducationSelect}><ModuleIcon kind="education" /><span><strong></strong><small></small></span></button>
</nav>
)}
<div className="module-heading"><span></span><button type="button" aria-label="添加模块"></button></div>
<nav className="platform-module-list" aria-label="已安装模块">
<button type="button" className={knowledgeSelected ? 'selected' : ''} onClick={onKnowledgeSelect}>
<ModuleIcon kind="knowledge" /><span></span>
</button>
<button type="button" onClick={onEducationSelect}>
<ModuleIcon kind="education" /><span></span>
</button>
</nav>
<div className="platform-nav-footer">

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/app.css';
import './styles/workspace-0.7.1.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View file

@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { expandWikiLinks, normalizeImportedMarkup, renderKnowledgeMarkdown } from './markdown.js';
test('expands HoloLake wiki links with labels', () => {
assert.equal(
expandWikiLinks('进入 [[WAKE-ROUTE|唤醒路径]]'),
'进入 [唤醒路径](hololake://knowledge/WAKE-ROUTE "WAKE-ROUTE")',
);
});
test('renders tables inside a horizontal scroll container', () => {
const html = renderKnowledgeMarkdown('| A | B |\n|---|---|\n| 1 | 2 |');
assert.match(html, /class="kb-table-scroll"/);
assert.match(html, /<table>/);
});
test('does not execute imported raw HTML or unsafe links', () => {
const html = renderKnowledgeMarkdown('<script>alert(1)</script>\n\n[x](javascript:alert(1))');
assert.doesNotMatch(html, /<script>/);
assert.match(html, /href="#"/);
});
test('renders Outline aside blocks as safe callouts and keeps table line breaks', () => {
const source = `<aside>\n👥\n\n责任主体说明。\n</aside>\n\n| 项目 | 说明 |\n| --- | --- |\n| 路径 | 第一行<br>第二行 |`;
const normalized = normalizeImportedMarkup(source);
assert.match(normalized, /> 👥/);
const html = renderKnowledgeMarkdown(source);
assert.match(html, /<blockquote>/);
assert.match(html, /责任主体说明/);
assert.doesNotMatch(html, /&lt;aside&gt;|<aside>/);
assert.match(html, /第一行<br>第二行/);
});

View file

@ -0,0 +1,64 @@
import { marked, Renderer } from 'marked';
const WIKI_LINK = /\[\[([^\]|\n]+?)(?:\|([^\]\n]+?))?\]\]/g;
const UNSAFE_SCHEME = /^(?:javascript|data|vbscript):/i;
export function normalizeImportedMarkup(source: string): string {
return source.replace(/<aside>\s*([\s\S]*?)\s*<\/aside>/giu, (_match, rawBody: string) => {
const body = rawBody.trim().split('\n').map(line => `> ${line}`).join('\n');
return `\n\n${body}\n\n`;
});
}
export function expandWikiLinks(source: string): string {
return source.replace(WIKI_LINK, (_match, rawTarget: string, rawLabel?: string) => {
const target = rawTarget.trim();
const label = (rawLabel || rawTarget).trim();
const encoded = encodeURIComponent(target).replace(/%2F/gi, '/');
return `[${label}](hololake://knowledge/${encoded} "${target.replace(/"/g, '&quot;')}")`;
});
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function safeHref(href: string | null): string {
const value = (href || '').trim();
return UNSAFE_SCHEME.test(value) ? '#' : value;
}
const renderer = new Renderer();
const renderBaseTable = renderer.table.bind(renderer);
renderer.html = ({ text }) => /^<br\s*\/?\s*>$/iu.test(text.trim())
? '<br>'
: `<pre class="kb-raw-html"><code>${escapeHtml(text)}</code></pre>`;
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const safe = escapeHtml(safeHref(href));
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
const wiki = safe.startsWith('hololake://knowledge/');
return `<a href="${safe}"${titleAttr}${wiki ? ' data-hololake-wiki="true"' : ''}>${label}</a>`;
};
renderer.image = ({ href, title, text }) => {
const safe = safeHref(href);
if (!safe || safe === '#') return `<span class="kb-image-blocked">图片地址已拦截</span>`;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${titleAttr} loading="lazy">`;
};
renderer.table = token => `<div class="kb-table-scroll">${renderBaseTable(token)}</div>`;
marked.setOptions({ gfm: true, breaks: false });
export function renderKnowledgeMarkdown(source: string): string {
try {
return marked.parse(expandWikiLinks(normalizeImportedMarkup(source)), { async: false, renderer }) as string;
} catch {
return '<p class="kb-render-error">此页面暂时无法渲染,请切换到编辑模式检查原文。</p>';
}
}

View file

@ -0,0 +1,266 @@
/* HoloLake 0.7.1 canonical workspace layer.
* This file is intentionally loaded after the inherited component stylesheet.
* It is the single layout and theme authority for the platform shell.
*/
.hololake-shell {
--lake-bg: #07111d;
--lake-panel: #0b1623;
--lake-surface: #101d2b;
--lake-surface-2: #152536;
--lake-reading: #091521;
--lake-border: #26384a;
--lake-border-soft: rgba(133, 160, 184, .17);
--lake-text: #edf3f7;
--lake-text-soft: #cbd5df;
--lake-muted: #8192a5;
--lake-accent: #58b9cc;
--lake-accent-soft: rgba(88, 185, 204, .12);
--lake-success: #55c99c;
--lake-warning: #d7a94e;
--lake-danger: #e2787c;
min-width: 760px;
color: var(--lake-text);
background: var(--lake-bg);
}
.hololake-shell[data-appearance="deep-night"] {
--lake-bg: #020811;
--lake-panel: #07111c;
--lake-surface: #0a1622;
--lake-surface-2: #0e1c2a;
--lake-reading: #040c15;
--lake-border: #1d3042;
--lake-muted: #708196;
}
.hololake-shell[data-appearance="mist-light"] {
--lake-bg: #eef2f5;
--lake-panel: #f7f9fb;
--lake-surface: #ffffff;
--lake-surface-2: #edf2f5;
--lake-reading: #fbfcfd;
--lake-border: #ccd6df;
--lake-border-soft: rgba(62, 82, 100, .14);
--lake-text: #172330;
--lake-text-soft: #344252;
--lake-muted: #687889;
--lake-accent: #247f94;
--lake-accent-soft: rgba(36, 127, 148, .1);
--lake-success: #24835d;
--lake-warning: #9a6917;
--lake-danger: #b94f55;
}
body, #root { background: var(--lake-bg, #07111d); }
.platform-topbar {
height: 58px;
display: grid;
grid-template-columns: 210px minmax(280px, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 0 14px 0 0;
border-bottom: 1px solid var(--lake-border-soft);
background: var(--lake-panel);
}
.topbar-channel { display: none; }
.topbar-search { min-width: 0; }
.topbar-search input { height: 36px; border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text); }
.theme-quick-toggle {
height: 34px;
min-width: 58px;
padding: 0 12px;
border: 1px solid var(--lake-border-soft);
border-radius: 17px;
background: var(--lake-surface);
color: var(--lake-text-soft);
font-size: 11px;
}
.theme-quick-toggle:hover { border-color: var(--lake-border); background: var(--lake-surface-2); }
.fifth-domain-session { height: 38px; min-width: 148px; border-color: var(--lake-border-soft); background: transparent; color: var(--lake-text); }
.fifth-domain-session:hover { background: var(--lake-surface); }
.fifth-domain-session small { color: var(--lake-muted); }
.platform-body { height: calc(100% - 58px); grid-template-columns: 210px minmax(0, 1fr); }
.platform-navigation {
min-width: 0;
padding: 12px 10px 10px;
border-right: 1px solid var(--lake-border-soft);
background: var(--lake-panel);
}
.platform-brand { min-height: 42px; height: auto; padding: 0 9px 10px; border: 0; }
.platform-brand strong { color: var(--lake-text); font-size: 16px; }
.platform-brand-orbit { width: 27px; height: 27px; }
.channel-selector { min-height: 56px; margin: 4px 0 9px; padding: 8px; border: 1px solid transparent; border-radius: 9px; background: var(--lake-accent-soft); color: var(--lake-text-soft); }
.channel-selector:hover { border-color: var(--lake-border-soft); background: var(--lake-accent-soft); }
.world-switcher { margin: 0 0 7px; }
.world-switcher-trigger { min-height: 44px; color: var(--lake-text-soft); }
.world-switcher-trigger:hover, .world-switcher-trigger[aria-expanded="true"] { border-color: var(--lake-border-soft); background: var(--lake-surface); }
.platform-route-list { z-index: 60; border-color: var(--lake-border); background: var(--lake-surface); }
.platform-route-list > button, .platform-module-list > button { color: var(--lake-muted); }
.platform-route-list > button:hover, .platform-module-list > button:hover { color: var(--lake-text); background: var(--lake-surface-2); }
.platform-route-list > button.selected, .platform-module-list > button.selected { color: var(--lake-text); background: var(--lake-accent-soft); }
.subdomain-navigation { margin: 4px 5px 10px; padding: 9px; border: 1px solid var(--lake-border-soft); border-radius: 9px; background: var(--lake-surface); }
.subdomain-navigation > small { display: block; margin: 0 4px 6px; color: var(--lake-muted); font-size: 9px; letter-spacing: .08em; }
.subdomain-navigation > button { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 7px; border: 0; border-radius: 7px; background: transparent; color: var(--lake-text-soft); text-align: left; }
.subdomain-navigation > button:hover { background: var(--lake-accent-soft); }
.subdomain-navigation svg { width: 17px; fill: none; stroke: var(--lake-accent); stroke-width: 1.7; }
.subdomain-navigation button > span { display: grid; gap: 1px; }
.subdomain-navigation strong { font-size: 11px; font-weight: 600; }
.subdomain-navigation button small { color: var(--lake-muted); font-size: 9px; }
.module-heading { padding: 10px 9px 5px; color: var(--lake-muted); }
.platform-module-list { padding: 3px 0; }
.platform-nav-footer { margin-top: auto; padding: 12px 9px 6px; border-top-color: var(--lake-border-soft); }
.platform-workspace { min-width: 0; height: 100%; overflow: hidden; background: var(--lake-bg); }
.knowledge-tabbar { height: 43px; min-height: 43px; border-bottom-color: var(--lake-border-soft); background: var(--lake-panel); }
.document-tab { border-color: var(--lake-border-soft); border-bottom-color: var(--lake-surface); background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-module-bar { height: 54px; min-height: 54px; padding: 0 14px; border-bottom-color: var(--lake-border-soft); background: var(--lake-panel); }
.storage-state-button { min-width: 210px; border: 0; background: transparent; color: var(--lake-text); }
.storage-state-button:hover { background: var(--lake-surface); }
.knowledge-module-actions button { color: var(--lake-muted); }
.knowledge-module-actions button:hover, .knowledge-module-actions button.selected { border-color: var(--lake-border-soft); background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-workbench { height: calc(100% - 97px); min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; background: var(--lake-reading); }
.knowledge-workbench.has-tree { grid-template-columns: var(--tree-width) 5px minmax(0, 1fr); }
.knowledge-workbench.has-agent { grid-template-columns: minmax(0, 1fr) 5px var(--agent-width); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: var(--tree-width) 5px minmax(0, 1fr) 5px var(--agent-width); }
.workspace-resizer { position: relative; z-index: 5; width: 5px; height: 100%; padding: 0; border: 0; background: transparent; cursor: col-resize; }
.workspace-resizer::after { content: ""; position: absolute; inset: 0 2px; background: var(--lake-border-soft); transition: background .15s; }
.workspace-resizer:hover::after, .is-resizing .workspace-resizer::after { background: var(--lake-accent); }
.is-resizing { cursor: col-resize; user-select: none; }
.knowledge-sidebar { width: auto; min-width: 0; height: 100%; flex: none; border-right: 0; background: var(--lake-panel); }
.knowledge-sidebar-heading { color: var(--lake-muted); }
.knowledge-sidebar .doc-tree, .knowledge-sidebar .tree-root, .knowledge-sidebar .kb-tree { min-height: 0; background: transparent; border: 0; color: var(--lake-text-soft); }
.knowledge-sidebar .kb-tree-item { color: var(--lake-muted); }
.knowledge-sidebar .kb-tree-item:hover { background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-sidebar .kb-tree-item.active { background: var(--lake-accent-soft); color: var(--lake-text); }
.import-button { border-color: var(--lake-border-soft); background: var(--lake-accent-soft); color: var(--lake-text-soft); }
.knowledge-content { min-width: 0; height: 100%; overflow: auto; background: var(--lake-reading); }
.knowledge-content > .editor, .knowledge-content > .kb-editor, .knowledge-content > .version-history { width: min(920px, calc(100% - 72px)); min-width: 0; margin: 46px auto 90px; }
.kb-editor { color: var(--lake-text-soft); }
.kb-page-symbol { color: var(--lake-accent); }
.kb-editor-meta, .kb-editor-path, .kb-editor-date { color: var(--lake-muted); }
.kb-editor-title, .kb-markdown-render h1, .kb-markdown-render h2, .kb-markdown-render h3, .kb-markdown-render h4 { color: var(--lake-text); }
.kb-editor-title { font-size: clamp(28px, 3.2vw, 46px); line-height: 1.18; }
.kb-editor-toolbar { opacity: 1; }
.kb-btn-primary { border-color: var(--lake-accent); background: var(--lake-accent); color: var(--lake-bg); }
.kb-btn-secondary { border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text-soft); }
.kb-editor-edit-pane { grid-template-columns: minmax(0, 1fr); }
.kb-editor-textarea { min-height: 380px; border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text-soft); }
.kb-editor-preview-pane { border-color: var(--lake-border); background: var(--lake-reading); }
.kb-markdown-render { min-width: 0; color: var(--lake-text-soft); font-family: var(--human-font); font-size: var(--reading-size, 17px); line-height: 1.82; overflow-wrap: anywhere; }
.kb-markdown-render p, .kb-markdown-render li { color: var(--lake-text-soft); }
.kb-markdown-render h1 { font-size: 1.9em; }
.kb-markdown-render h2 { font-size: 1.45em; padding-top: .35em; }
.kb-markdown-render h3 { font-size: 1.18em; }
.kb-markdown-render blockquote { border-left-color: var(--lake-accent); background: var(--lake-accent-soft); color: var(--lake-muted); }
.kb-markdown-render code { background: var(--lake-surface-2); color: var(--lake-text); }
.kb-markdown-render pre { max-width: 100%; background: var(--lake-panel); color: var(--lake-text-soft); }
.kb-markdown-render a { color: var(--lake-accent); }
.kb-markdown-render a[data-hololake-wiki="true"] { padding: 1px 4px; border-radius: 4px; background: var(--lake-accent-soft); text-decoration: none; }
.kb-table-scroll { width: 100%; max-width: 100%; margin: 1.25em 0; overflow-x: auto; border: 1px solid var(--lake-border); border-radius: 8px; }
.kb-table-scroll table, .kb-markdown-render table { width: max-content; min-width: 100%; margin: 0; border: 0; border-collapse: collapse; }
.kb-markdown-render th, .kb-markdown-render td { min-width: 120px; padding: 10px 12px; border: 0; border-right: 1px solid var(--lake-border); border-bottom: 1px solid var(--lake-border); color: var(--lake-text-soft); vertical-align: top; }
.kb-markdown-render th { background: var(--lake-surface-2); color: var(--lake-text); }
.kb-markdown-render tr:last-child td { border-bottom: 0; }
.kb-markdown-render th:last-child, .kb-markdown-render td:last-child { border-right: 0; }
.kb-raw-html { border: 1px solid var(--lake-border); }
.document-path { color: var(--lake-muted); }
.agent-drawer { position: relative; inset: auto; z-index: 1; width: auto; height: 100%; min-width: 0; border: 0; border-radius: 0; background: var(--lake-panel); box-shadow: none; backdrop-filter: none; }
.agent-drawer-scope { min-height: 39px; padding: 7px 12px; border-bottom-color: var(--lake-border-soft); color: var(--lake-muted); }
.agent-drawer-scope strong { color: var(--lake-text-soft); }
.agent-chat { min-width: 0; background: var(--lake-panel); color: var(--lake-text); }
.agent-header { min-height: 60px; padding: 10px 12px; border-bottom-color: var(--lake-border-soft); }
.agent-info { min-width: 0; }
.agent-meta { min-width: 0; }
.agent-meta h3 { color: var(--lake-text); }
.agent-role { display: block; max-width: 240px; overflow: hidden; color: var(--lake-muted); text-overflow: ellipsis; white-space: nowrap; }
.agent-actions { display: flex; flex: 0 0 auto; gap: 4px; }
.agent-actions button { min-width: 30px; height: 30px; padding: 0 8px; border: 0; border-radius: 7px; background: transparent; color: var(--lake-muted); white-space: nowrap; }
.agent-actions button[title="历史记录"] { min-width: 44px; }
.agent-actions button:hover, .agent-actions button.selected { background: var(--lake-surface-2); color: var(--lake-text); }
.runtime-status { border-bottom-color: var(--lake-border-soft); }
.runtime-row { color: var(--lake-muted); }
.runtime-tools { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
.agent-body.history-open { grid-template-columns: minmax(160px, 42%) minmax(0, 1fr); }
.agent-history { min-width: 0; overflow: hidden; border-right: 1px solid var(--lake-border-soft); background: var(--lake-surface); }
.agent-history-heading { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--lake-border-soft); }
.agent-history-heading strong { font-size: 11px; }
.agent-history-heading button { border: 0; background: transparent; color: var(--lake-accent); font-size: 10px; }
.agent-history-list { height: calc(100% - 44px); overflow: auto; padding: 6px; }
.agent-history-item { display: grid; grid-template-columns: minmax(0, 1fr) 24px; gap: 2px; margin-bottom: 3px; border-radius: 7px; }
.agent-history-item.selected { background: var(--lake-accent-soft); }
.agent-history-item > button { min-width: 0; padding: 8px; border: 0; background: transparent; color: var(--lake-text-soft); text-align: left; }
.agent-history-item strong, .agent-history-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-history-item strong { font-size: 10px; }
.agent-history-item small { margin-top: 3px; color: var(--lake-muted); font-size: 8px; }
.agent-history-item > button.delete { align-self: center; padding: 0; color: var(--lake-muted); text-align: center; }
.agent-history-item > button.delete:hover { color: var(--lake-danger); }
.agent-conversation { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.agent-messages { flex: 1; min-height: 0; padding: 14px 12px; overflow-y: auto; background: transparent; }
.agent-welcome { align-items: flex-start; padding: 28px 10px; text-align: left; }
.agent-welcome h2 { color: var(--lake-text); font-size: 18px; }
.agent-welcome p { color: var(--lake-muted); font-size: 12px; line-height: 1.7; }
.msg-user-bubble { background: var(--lake-accent-soft); color: var(--lake-text); }
.msg-assistant-bubble { color: var(--lake-text-soft); }
.msg-markdown { color: var(--lake-text-soft); }
.msg-markdown p, .msg-markdown li { color: inherit; }
.agent-activity { margin: 0 0 12px; border: 1px solid var(--lake-border-soft); border-radius: 9px; background: var(--lake-surface); }
.agent-activity summary { padding: 9px 10px; color: var(--lake-text-soft); font-size: 10px; cursor: pointer; }
.agent-activity ol { margin: 0; padding: 0 9px 9px; list-style: none; }
.agent-activity li { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 7px 2px; border-top: 1px solid var(--lake-border-soft); }
.agent-activity li > span { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--lake-success); }
.agent-activity li[data-status="pending"] > span { background: var(--lake-warning); }
.agent-activity li[data-status="failed"] > span { background: var(--lake-danger); }
.agent-activity li div { min-width: 0; }
.agent-activity strong, .agent-activity small { display: block; }
.agent-activity strong { color: var(--lake-text-soft); font-size: 10px; }
.agent-activity small { margin-top: 3px; overflow: hidden; color: var(--lake-muted); font-size: 9px; line-height: 1.45; text-overflow: ellipsis; }
.agent-activity em { color: var(--lake-muted); font-size: 8px; font-style: normal; }
.pending-action { border-color: var(--lake-border); background: var(--lake-surface); }
.agent-input-area { padding: 10px 12px 12px; border-top-color: var(--lake-border-soft); background: var(--lake-panel); }
.agent-input-wrapper { border-color: var(--lake-border); background: var(--lake-surface); }
.agent-input { color: var(--lake-text); }
.agent-hint { color: var(--lake-muted); }
.btn-send { background: var(--lake-accent); color: var(--lake-bg); }
.agent-launcher { right: 18px; bottom: 16px; height: 42px; border-color: var(--lake-border); background: var(--lake-surface-2); color: var(--lake-text-soft); }
.agent-launcher:hover { background: var(--lake-accent-soft); }
.domain-surface { background: var(--lake-bg); }
.domain-module-card, .domain-connection { border-color: var(--lake-border-soft); background: var(--lake-panel); }
.domain-module-card:hover { background: var(--lake-surface); }
.storage-sheet, .human-settings { border-color: var(--lake-border); background: var(--lake-panel); color: var(--lake-text); }
.storage-sheet-backdrop, .human-settings-backdrop { background: rgba(2, 8, 14, .56); }
.settings-section select, .settings-model-fields input { border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text); }
.settings-model-card, .settings-account-card { border-color: var(--lake-border-soft); background: var(--lake-surface); }
@media (max-width: 1500px) {
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(380px, var(--agent-width)); }
.knowledge-workbench.has-tree.has-agent .knowledge-sidebar,
.knowledge-workbench.has-tree.has-agent .tree-resizer { display: none; }
}
@media (max-width: 1100px) {
.platform-topbar { grid-template-columns: 188px minmax(240px, 1fr) auto auto; }
.platform-body { grid-template-columns: 188px minmax(0, 1fr); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(360px, var(--agent-width)); }
.knowledge-content > .editor, .knowledge-content > .kb-editor, .knowledge-content > .version-history { width: calc(100% - 40px); }
}
@media (max-width: 860px) {
.theme-quick-toggle { display: none; }
.platform-topbar { grid-template-columns: 172px minmax(220px, 1fr) auto; }
.platform-body { grid-template-columns: 172px minmax(0, 1fr); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(360px, var(--agent-width)); }
.knowledge-workbench.has-tree.has-agent .knowledge-sidebar, .knowledge-workbench.has-tree.has-agent .tree-resizer { display: none; }
}