fix(hololake): ship dynamic persona routes and coherent workspace
This commit is contained in:
parent
5105ec5e32
commit
787ba2ad3f
21 changed files with 1111 additions and 326 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue