feat: expand page ai hermes control surface
This commit is contained in:
@@ -22,6 +22,7 @@ pub struct CreateSessionRequest {
|
||||
document_id: Option<String>,
|
||||
trace_id: Option<String>,
|
||||
title: Option<String>,
|
||||
profile: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
@@ -60,6 +61,12 @@ pub async fn create_session(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("current");
|
||||
let session_id = stable_session_id(document_id, &trace_id);
|
||||
let profile = payload
|
||||
.profile
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("default");
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
@@ -68,6 +75,7 @@ pub async fn create_session(
|
||||
"sessionId": session_id,
|
||||
"workspaceId": payload.workspace_id,
|
||||
"documentId": payload.document_id,
|
||||
"profile": profile,
|
||||
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
||||
"traceId": trace_id,
|
||||
"persistence": "hermes_on_first_run"
|
||||
@@ -75,6 +83,133 @@ pub async fn create_session(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_profiles(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
"/api/hermes/profiles",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(profile_name): Path<String>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
&format!("/api/hermes/profiles/{}", url_escape(&profile_name)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn switch_active_profile(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::PUT,
|
||||
&upstream,
|
||||
"/api/hermes/profiles/active",
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_profile_memory(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
"/api/hermes/memory",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_profile_memory(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::POST,
|
||||
&upstream,
|
||||
"/api/hermes/memory",
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_skills(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let mut path = "/api/hermes/skills".to_string();
|
||||
if !query.is_empty() {
|
||||
let params = query
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push('?');
|
||||
path.push_str(¶ms);
|
||||
}
|
||||
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
|
||||
}
|
||||
|
||||
pub async fn toggle_skill(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::PUT,
|
||||
&upstream,
|
||||
"/api/hermes/skills/toggle",
|
||||
Some(payload),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -335,10 +470,16 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
|
||||
let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
|
||||
let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
|
||||
let profile = payload
|
||||
.get("profile")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let instructions = json!({
|
||||
"role": "mnote_page_ai_context",
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"profile": profile.unwrap_or("default"),
|
||||
"actorId": context.auth.actor_id,
|
||||
"actorType": context.auth.actor_type,
|
||||
"sessionId": session_id,
|
||||
@@ -362,6 +503,9 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
{
|
||||
body["model"] = Value::String(model.to_string());
|
||||
}
|
||||
if let Some(profile) = profile {
|
||||
body["profile"] = Value::String(profile.to_string());
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -664,6 +808,39 @@ mod tests {
|
||||
assert!(payload.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/client/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"traceId": "trace_1",
|
||||
"title": "当前页问答",
|
||||
"profile": "chemist"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
||||
assert_eq!(payload["profile"], "chemist");
|
||||
assert!(payload.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_client_run_body_carries_page_context_into_run_input() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
@@ -693,4 +870,77 @@ mod tests {
|
||||
assert!(instructions.contains("\"title\":\"页面标题\""));
|
||||
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_client_run_body_carries_agent_profile() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/client/runs".parse().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
let body = build_run_upstream_body(
|
||||
&context,
|
||||
json!({
|
||||
"workspaceId": "ws_1",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"message": "用当前 agent 回答",
|
||||
"profile": "chemist",
|
||||
"traceId": "trace_1"
|
||||
}),
|
||||
)
|
||||
.expect("body");
|
||||
assert_eq!(body["profile"], "chemist");
|
||||
let instructions = body["instructions"].as_str().expect("instructions");
|
||||
assert!(instructions.contains("\"profile\":\"chemist\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_profile_skill_and_memory_routes_exist_with_stable_unconfigured_error() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
|
||||
std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
|
||||
let cases = [
|
||||
("GET", "/api/hermes/client/profiles", None),
|
||||
("GET", "/api/hermes/client/profiles/chemist", None),
|
||||
("GET", "/api/hermes/client/profile-memory?profile=chemist", None),
|
||||
("GET", "/api/hermes/client/skills?profile=chemist", None),
|
||||
(
|
||||
"PUT",
|
||||
"/api/hermes/client/profiles/active",
|
||||
Some(json!({"name": "chemist"})),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/hermes/client/profile-memory",
|
||||
Some(json!({"profile": "chemist", "section": "soul", "content": "test"})),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/api/hermes/client/skills/toggle",
|
||||
Some(json!({"name": "chem", "enabled": true})),
|
||||
),
|
||||
];
|
||||
for (method, uri, body) in cases {
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
body.unwrap_or_else(|| json!({})).to_string(),
|
||||
))
|
||||
.expect("request");
|
||||
let response = app().oneshot(request).await.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("hermes_client_unconfigured"),
|
||||
"{uri}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::routing::{any, get, post, put};
|
||||
use axum::Router;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -145,6 +145,28 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session),
|
||||
)
|
||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||
.route(
|
||||
"/client/profiles/active",
|
||||
put(hermes_client::switch_active_profile),
|
||||
)
|
||||
.route(
|
||||
"/client/profiles/{profile_name}",
|
||||
get(hermes_client::get_profile),
|
||||
)
|
||||
.route(
|
||||
"/client/profile-memory",
|
||||
get(hermes_client::get_profile_memory)
|
||||
.post(hermes_client::save_profile_memory),
|
||||
)
|
||||
.route(
|
||||
"/client/skills",
|
||||
get(hermes_client::list_skills),
|
||||
)
|
||||
.route(
|
||||
"/client/skills/toggle",
|
||||
put(hermes_client::toggle_skill),
|
||||
)
|
||||
.route("/client/runs", post(hermes_client::create_run))
|
||||
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
||||
.route(
|
||||
|
||||
@@ -36,6 +36,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiSuggestionIndex: 0,
|
||||
pageAiProvider: 'hermes',
|
||||
pageAiPage: 'chat',
|
||||
pageAiRunStatus: 'idle',
|
||||
pageAiCurrentRunId: '',
|
||||
pageAiAbortController: null,
|
||||
pageAiContextScope: 'page',
|
||||
pageAiProfiles: [],
|
||||
pageAiActiveProfileName: 'default',
|
||||
pageAiProfileError: '',
|
||||
pageAiProfileMemory: { memory: '', user: '', soul: '' },
|
||||
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
|
||||
pageAiProfileMemoryError: '',
|
||||
pageAiSkills: { categories: [], archived: [] },
|
||||
pageAiSkillQuery: '',
|
||||
pageAiSkillError: '',
|
||||
pageAiSessions: [],
|
||||
pageAiActiveSessionId: ''
|
||||
};
|
||||
@@ -3022,6 +3035,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return {
|
||||
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
|
||||
title: title || '新会话',
|
||||
profile: pageAiCurrentProfile(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: []
|
||||
@@ -3035,6 +3049,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return {
|
||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
createdAt: Number(session && session.createdAt || Date.now()),
|
||||
updatedAt: Number(session && session.updatedAt || Date.now()),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-40) : []
|
||||
@@ -3051,6 +3066,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var raw = window.localStorage.getItem(pageAiStorageKey());
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
||||
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
||||
if (activeId) {
|
||||
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
|
||||
pageUiState.pageAiSessions[0].id = activeId;
|
||||
@@ -3070,15 +3087,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
try {
|
||||
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
activeProfileName: pageAiCurrentProfile()
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pageAiEnsureHermesSession() {
|
||||
async function pageAiEnsureHermesSession(forceCreate) {
|
||||
pageAiLoadSessions();
|
||||
var current = pageAiCurrentSession();
|
||||
if (current && String(current.id || '').startsWith('mnote_')) return current;
|
||||
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === pageAiCurrentProfile()) return current;
|
||||
var response = await fetch('/api/hermes/client/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -3086,6 +3104,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
profile: pageAiCurrentProfile(),
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
})
|
||||
});
|
||||
@@ -3097,6 +3116,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var session = {
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
profile: String(payload.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
@@ -3118,6 +3138,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
if (session && (session.profile || session.profileName)) {
|
||||
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
|
||||
}
|
||||
if (!messages.length) return;
|
||||
pageUiState.pageAiMessages = messages.slice(-40).map(function(message) {
|
||||
return {
|
||||
@@ -3143,6 +3166,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
@@ -3153,6 +3177,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
@@ -3162,6 +3187,316 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return 'Hermes';
|
||||
}
|
||||
|
||||
function pageAiNormalizeArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function pageAiUnwrapUpstream(payload) {
|
||||
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
|
||||
return payload || null;
|
||||
}
|
||||
|
||||
function pageAiProfileValue(profile) {
|
||||
if (profile && typeof profile === 'object') {
|
||||
return String(profile.name || profile.profile || profile.id || '').trim();
|
||||
}
|
||||
return String(profile || '').trim();
|
||||
}
|
||||
|
||||
function pageAiCurrentProfile() {
|
||||
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
|
||||
if (active) return active;
|
||||
var selected = pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return profile && profile.active;
|
||||
});
|
||||
return pageAiProfileValue(selected) || 'default';
|
||||
}
|
||||
|
||||
function pageAiNormalizeProfiles(payload) {
|
||||
var upstream = pageAiUnwrapUpstream(payload);
|
||||
var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream);
|
||||
return profiles.map(function(profile) {
|
||||
return {
|
||||
name: pageAiProfileValue(profile) || 'default',
|
||||
active: Boolean(profile && profile.active),
|
||||
model: String(profile && profile.model || '').trim(),
|
||||
gateway: String(profile && profile.gateway || '').trim(),
|
||||
alias: String(profile && profile.alias || '').trim()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeSkills(payload) {
|
||||
var upstream = pageAiUnwrapUpstream(payload);
|
||||
var categories = pageAiNormalizeArray(upstream && upstream.categories ? upstream.categories : []);
|
||||
var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []);
|
||||
return {
|
||||
categories: categories.map(function(category) {
|
||||
return {
|
||||
name: String(category && category.name || '').trim() || 'misc',
|
||||
description: String(category && category.description || '').trim(),
|
||||
skills: pageAiNormalizeArray(category && category.skills).map(function(skill) {
|
||||
return {
|
||||
name: String(skill && skill.name || '').trim(),
|
||||
description: String(skill && skill.description || '').trim(),
|
||||
enabled: skill && skill.enabled !== false,
|
||||
source: String(skill && skill.source || 'local').trim() || 'local',
|
||||
modified: Boolean(skill && skill.modified)
|
||||
};
|
||||
})
|
||||
};
|
||||
}),
|
||||
archived: archived.map(function(skill) {
|
||||
return {
|
||||
name: String(skill && skill.name || '').trim(),
|
||||
description: String(skill && skill.description || '').trim(),
|
||||
enabled: skill && skill.enabled !== false,
|
||||
source: String(skill && skill.source || 'local').trim() || 'local',
|
||||
modified: Boolean(skill && skill.modified)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiSkillListEntries() {
|
||||
var result = [];
|
||||
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
|
||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||
result.push({
|
||||
category: category.name,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
enabled: skill.enabled !== false,
|
||||
source: skill.source || 'local',
|
||||
modified: Boolean(skill.modified)
|
||||
});
|
||||
});
|
||||
});
|
||||
return result.concat(pageAiNormalizeArray(pageUiState.pageAiSkills.archived));
|
||||
}
|
||||
|
||||
function pageAiSetActiveProfile(profileName) {
|
||||
var next = String(profileName || '').trim() || 'default';
|
||||
pageUiState.pageAiActiveProfileName = next;
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-profile', next);
|
||||
}
|
||||
|
||||
function pageAiSetRunStatus(status, runId) {
|
||||
pageUiState.pageAiRunStatus = status || 'idle';
|
||||
pageUiState.pageAiCurrentRunId = runId || pageUiState.pageAiCurrentRunId || '';
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-run-status', pageUiState.pageAiRunStatus);
|
||||
if (pageUiState.pageAiCurrentRunId) {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId);
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiSetContextScope(scope) {
|
||||
var next = String(scope || '').trim() || 'page';
|
||||
pageUiState.pageAiContextScope = next;
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-context-scope', next);
|
||||
}
|
||||
|
||||
function pageAiRunStatusLabel(status) {
|
||||
if (status === 'queued') return '排队中';
|
||||
if (status === 'running') return '运行中';
|
||||
if (status === 'tool_calling') return '调用工具';
|
||||
if (status === 'completed') return '已完成';
|
||||
if (status === 'failed') return '失败';
|
||||
if (status === 'aborted') return '已停止';
|
||||
return '空闲';
|
||||
}
|
||||
|
||||
function pageAiMemoryFileLabel(section) {
|
||||
if (section === 'soul') return 'SOUL.md';
|
||||
if (section === 'user') return 'USER.md';
|
||||
return 'MEMORY.md';
|
||||
}
|
||||
|
||||
function pageAiContextScopeLabel(scope) {
|
||||
if (scope === 'selection') return '当前选区';
|
||||
if (scope === 'block') return '当前块';
|
||||
if (scope === 'options') return '页面设置';
|
||||
return '当前页';
|
||||
}
|
||||
|
||||
function pageAiSetDraftForSection(section, value) {
|
||||
pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value);
|
||||
}
|
||||
|
||||
function pageAiApplyProfileMemory(payload) {
|
||||
var upstream = pageAiUnwrapUpstream(payload) || {};
|
||||
pageUiState.pageAiProfileMemory = {
|
||||
memory: String(upstream.memory || ''),
|
||||
user: String(upstream.user || ''),
|
||||
soul: String(upstream.soul || '')
|
||||
};
|
||||
pageUiState.pageAiProfileMemoryDrafts = {
|
||||
memory: pageUiState.pageAiProfileMemory.memory,
|
||||
user: pageUiState.pageAiProfileMemory.user,
|
||||
soul: pageUiState.pageAiProfileMemory.soul
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiFilteredSkillEntries() {
|
||||
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase();
|
||||
return pageAiSkillListEntries().filter(function(skill) {
|
||||
if (!query) return true;
|
||||
return String(skill.name || '').toLowerCase().indexOf(query) >= 0
|
||||
|| String(skill.description || '').toLowerCase().indexOf(query) >= 0
|
||||
|| String(skill.category || '').toLowerCase().indexOf(query) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function pageAiLoadProfiles() {
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/profiles', {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profiles_failed_' + response.status);
|
||||
var profiles = pageAiNormalizeProfiles(payload);
|
||||
pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'default', active: true }];
|
||||
var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; }));
|
||||
pageAiSetActiveProfile(active || pageAiCurrentProfile());
|
||||
pageUiState.pageAiProfileError = '';
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
|
||||
if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'default', active: true }];
|
||||
pageAiSetActiveProfile(pageAiCurrentProfile());
|
||||
}
|
||||
renderPageAiProviderButtons();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiSwitchProfile(profileName) {
|
||||
var next = String(profileName || '').trim();
|
||||
if (!next) return;
|
||||
pageAiSetActiveProfile(next);
|
||||
renderPageAiProviderButtons();
|
||||
renderPageAiControls();
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/profiles/active', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next })
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_switch_failed_' + response.status);
|
||||
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) {
|
||||
return session && session.profile === next;
|
||||
});
|
||||
pageUiState.pageAiActiveSessionId = '';
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageAiPersistSessions();
|
||||
await pageAiEnsureHermesSession(true);
|
||||
await pageAiLoadProfileMemory();
|
||||
await pageAiLoadSkills();
|
||||
renderPageAiControls();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiLoadProfileMemory() {
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/profile-memory?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_memory_failed_' + response.status);
|
||||
pageAiApplyProfileMemory(payload);
|
||||
pageUiState.pageAiProfileMemoryError = '';
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiSaveProfileMemory(section) {
|
||||
var normalized = String(section || '').trim();
|
||||
if (['memory', 'user', 'soul'].indexOf(normalized) < 0) return;
|
||||
var content = String(pageUiState.pageAiProfileMemoryDrafts[normalized] || '');
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/profile-memory', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile: pageAiCurrentProfile(),
|
||||
section: normalized,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_memory_save_failed_' + response.status);
|
||||
pageUiState.pageAiProfileMemory[normalized] = content;
|
||||
pageUiState.pageAiProfileMemoryError = '';
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized);
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiLoadSkills() {
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/skills?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'skills_failed_' + response.status);
|
||||
pageUiState.pageAiSkills = pageAiNormalizeSkills(payload);
|
||||
pageUiState.pageAiSkillError = '';
|
||||
} catch (error) {
|
||||
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
}
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiToggleSkill(skillName, enabled) {
|
||||
var name = String(skillName || '').trim();
|
||||
if (!name) return;
|
||||
var previous = null;
|
||||
pageAiSkillListEntries().forEach(function(skill) {
|
||||
if (skill.name === name && previous == null) previous = skill.enabled !== false;
|
||||
});
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/skills/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile: pageAiCurrentProfile(),
|
||||
name: name,
|
||||
enabled: Boolean(enabled)
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'skill_toggle_failed_' + response.status);
|
||||
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
|
||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||
if (skill.name === name) skill.enabled = Boolean(enabled);
|
||||
});
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name);
|
||||
pageUiState.pageAiSkillError = '';
|
||||
} catch (error) {
|
||||
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
|
||||
if (previous != null) {
|
||||
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
|
||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||
if (skill.name === name) skill.enabled = previous;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function renderPageAiProviderButtons() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
|
||||
@@ -3176,6 +3511,103 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function renderPageAiControls() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var activeProfile = pageAiCurrentProfile();
|
||||
var profileSelect = drawer.querySelector('[data-page-ai-profile-select]');
|
||||
if (profileSelect instanceof HTMLSelectElement) {
|
||||
var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }];
|
||||
profileSelect.innerHTML = profiles.map(function(profile) {
|
||||
var name = pageAiProfileValue(profile) || 'default';
|
||||
var label = profile.alias ? name + ' · ' + profile.alias : name;
|
||||
return '<option value="' + escapeHtml(name) + '"' + (name === activeProfile ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
|
||||
}).join('');
|
||||
profileSelect.value = activeProfile;
|
||||
}
|
||||
var runStatus = drawer.querySelector('[data-page-ai-run-status]');
|
||||
if (runStatus instanceof HTMLElement) runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus);
|
||||
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
||||
if (sessionNode instanceof HTMLElement) {
|
||||
var session = pageAiCurrentSession();
|
||||
sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session';
|
||||
}
|
||||
var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]');
|
||||
if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope;
|
||||
var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]');
|
||||
if (scopeLabel instanceof HTMLElement) scopeLabel.textContent = pageAiContextScopeLabel(pageUiState.pageAiContextScope);
|
||||
drawer.querySelectorAll('[data-page-ai-tab]').forEach(function(button) {
|
||||
var target = button.getAttribute('data-page-ai-tab') || 'chat';
|
||||
var active = target === pageUiState.pageAiPage;
|
||||
button.classList.toggle('is-active', active);
|
||||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
drawer.querySelectorAll('[data-page-ai-panel]').forEach(function(panel) {
|
||||
if (panel instanceof HTMLElement) {
|
||||
panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage;
|
||||
}
|
||||
});
|
||||
var profileError = drawer.querySelector('[data-page-ai-profile-error]');
|
||||
if (profileError instanceof HTMLElement) {
|
||||
profileError.textContent = pageUiState.pageAiProfileError || '';
|
||||
profileError.hidden = !pageUiState.pageAiProfileError;
|
||||
}
|
||||
var memoryError = drawer.querySelector('[data-page-ai-memory-error]');
|
||||
if (memoryError instanceof HTMLElement) {
|
||||
memoryError.textContent = pageUiState.pageAiProfileMemoryError || '';
|
||||
memoryError.hidden = !pageUiState.pageAiProfileMemoryError;
|
||||
}
|
||||
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
|
||||
if (agentPanel instanceof HTMLElement) {
|
||||
agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) {
|
||||
var label = pageAiMemoryFileLabel(section);
|
||||
var value = pageUiState.pageAiProfileMemoryDrafts[section] || '';
|
||||
return '' +
|
||||
'<section class="wolai-page-ai-memory-card">' +
|
||||
'<div class="wolai-page-ai-memory-head">' +
|
||||
'<div>' +
|
||||
'<div class="wolai-page-ai-memory-title">' + escapeHtml(label) + '</div>' +
|
||||
'<div class="wolai-page-ai-memory-scope">保存到 Hermes profile: ' + escapeHtml(activeProfile) + '</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-memory-save="' + escapeHtml(section) + '">保存</button>' +
|
||||
'</div>' +
|
||||
'<textarea class="wolai-page-ai-memory-editor" data-page-ai-memory-editor="' + escapeHtml(section) + '" spellcheck="false">' + escapeHtml(value) + '</textarea>' +
|
||||
'</section>';
|
||||
}).join('');
|
||||
}
|
||||
var skillError = drawer.querySelector('[data-page-ai-skill-error]');
|
||||
if (skillError instanceof HTMLElement) {
|
||||
skillError.textContent = pageUiState.pageAiSkillError || '';
|
||||
skillError.hidden = !pageUiState.pageAiSkillError;
|
||||
}
|
||||
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
|
||||
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
|
||||
skillSearch.value = pageUiState.pageAiSkillQuery;
|
||||
}
|
||||
var skillList = drawer.querySelector('[data-page-ai-skill-list]');
|
||||
if (skillList instanceof HTMLElement) {
|
||||
var skills = pageAiFilteredSkillEntries();
|
||||
if (!skills.length) {
|
||||
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的 Hermes skill。</div>';
|
||||
} else {
|
||||
skillList.innerHTML = skills.map(function(skill) {
|
||||
var source = skill.source || 'local';
|
||||
var sourceText = source + (skill.modified ? ' · modified' : '');
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-skill-row">' +
|
||||
'<div class="wolai-page-ai-skill-copy">' +
|
||||
'<div class="wolai-page-ai-skill-name">' + escapeHtml(skill.name) + '</div>' +
|
||||
'<div class="wolai-page-ai-skill-desc">' + escapeHtml(skill.description || '无描述') + '</div>' +
|
||||
'<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '">' +
|
||||
'<span></span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function humanizePageAiResponse(rawText, promptText) {
|
||||
var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider);
|
||||
var text = String(rawText || '').trim();
|
||||
@@ -3210,32 +3642,85 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-header">' +
|
||||
'<div class="wolai-page-ai-header-copy">' +
|
||||
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">智能问答</h2>' +
|
||||
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">页面 AI</h2>' +
|
||||
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · Hermes</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-body">' +
|
||||
'<div class="wolai-page-ai-suggestions">' +
|
||||
'<div class="wolai-page-ai-suggestions-header">' +
|
||||
'<span>推荐问题</span>' +
|
||||
'<div class="wolai-page-ai-intents">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
||||
'<div class="wolai-page-ai-statusbar">' +
|
||||
'<div class="wolai-page-ai-statusitem">' +
|
||||
'<span class="wolai-page-ai-statuslabel">会话</span>' +
|
||||
'<span class="wolai-page-ai-statusvalue" data-page-ai-session-status>等待 Hermes session</span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'<div class="wolai-page-ai-statusitem">' +
|
||||
'<span class="wolai-page-ai-statuslabel">状态</span>' +
|
||||
'<span class="wolai-page-ai-statusvalue" data-page-ai-run-status>空闲</span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-statusitem">' +
|
||||
'<span class="wolai-page-ai-statuslabel">上下文</span>' +
|
||||
'<span class="wolai-page-ai-statusvalue" data-page-ai-context-scope-label>当前页</span>' +
|
||||
'</div>' +
|
||||
'<a class="wolai-page-ai-settings-link" href="/auth" target="_blank" rel="noreferrer">Hermes 设置</a>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-topbar">' +
|
||||
'<label class="wolai-page-ai-profile-select">' +
|
||||
'<span>agent / profile</span>' +
|
||||
'<select data-page-ai-profile-select></select>' +
|
||||
'</label>' +
|
||||
'<label class="wolai-page-ai-context-select">' +
|
||||
'<span>上下文</span>' +
|
||||
'<select data-page-ai-context-scope>' +
|
||||
'<option value="page">当前页</option>' +
|
||||
'<option value="selection">当前选区</option>' +
|
||||
'<option value="block">当前块</option>' +
|
||||
'<option value="options">页面设置</option>' +
|
||||
'</select>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-inline-error" data-page-ai-profile-error hidden></div>' +
|
||||
'<div class="wolai-page-ai-body">' +
|
||||
'<div class="wolai-page-ai-tabs" role="tablist" aria-label="页面 AI 面板">' +
|
||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="chat" role="tab" aria-selected="true">chat</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">agent</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="skills" role="tab" aria-selected="false">skills</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="history" role="tab" aria-selected="false">history</button>' +
|
||||
'</div>' +
|
||||
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' +
|
||||
'<div class="wolai-page-ai-suggestions">' +
|
||||
'<div class="wolai-page-ai-suggestions-header">' +
|
||||
'<span>推荐问题</span>' +
|
||||
'<div class="wolai-page-ai-intents">' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'</section>' +
|
||||
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="agent" hidden>' +
|
||||
'<div class="wolai-page-ai-inline-error" data-page-ai-memory-error hidden></div>' +
|
||||
'<div class="wolai-page-ai-memory-grid" data-page-ai-agent-panel></div>' +
|
||||
'</section>' +
|
||||
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="skills" hidden>' +
|
||||
'<div class="wolai-page-ai-skills-toolbar">' +
|
||||
'<label class="wolai-page-ai-skill-search">' +
|
||||
'<span>搜索技能</span>' +
|
||||
'<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-inline-error" data-page-ai-skill-error hidden></div>' +
|
||||
'<div class="wolai-page-ai-skill-list" data-page-ai-skill-list></div>' +
|
||||
'</section>' +
|
||||
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="history" hidden>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'</section>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-footer">' +
|
||||
'<div class="wolai-page-ai-toolbar">' +
|
||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
||||
'<div class="wolai-page-ai-provider-group">' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-input-row">' +
|
||||
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
|
||||
@@ -3249,7 +3734,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function renderPageAiSuggestions() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var list = drawer.querySelector('[data-page-ai-suggestion-list]');
|
||||
var list = drawer.querySelector('[data-page-ai-panel="chat"] [data-page-ai-suggestion-list]');
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
var suggestions = pageAiSuggestions();
|
||||
var offset = pageUiState.pageAiSuggestionIndex % suggestions.length;
|
||||
@@ -3261,7 +3746,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function renderPageAiConversation() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var conversation = drawer.querySelector('[data-page-ai-conversation]');
|
||||
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
|
||||
if (!(conversation instanceof HTMLElement)) return;
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
if (!pageUiState.pageAiSessions.length) {
|
||||
@@ -3301,18 +3786,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiSuggestions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiProviderButtons();
|
||||
renderPageAiControls();
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
pageAiEnsureHermesSession().then(function() {
|
||||
Promise.all([
|
||||
pageAiLoadProfiles(),
|
||||
pageAiLoadProfileMemory(),
|
||||
pageAiLoadSkills()
|
||||
]).then(function() {
|
||||
renderPageAiControls();
|
||||
}).catch(function() {}).then(function() {
|
||||
return pageAiEnsureHermesSession();
|
||||
}).then(function() {
|
||||
return pageAiRestoreHermesSession();
|
||||
}).then(function() {
|
||||
renderPageAiControls();
|
||||
}).catch(function(error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3366,6 +3863,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var currentSession = null;
|
||||
try {
|
||||
await pageAiEnsureHermesSession();
|
||||
pageAiSetRunStatus('queued');
|
||||
var contextSnapshot = currentPageAiContextSnapshot();
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
@@ -3388,6 +3886,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
profile: pageAiCurrentProfile(),
|
||||
message: prompt,
|
||||
model: 'hermes-agent',
|
||||
pageContext: {
|
||||
@@ -3415,6 +3914,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
|
||||
var runId = upstream && (upstream.run_id || upstream.runId);
|
||||
if (!runId) throw new Error('hermes_run_missing_run_id');
|
||||
pageAiSetRunStatus('running', runId);
|
||||
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
|
||||
headers: { 'accept': 'text/event-stream' }
|
||||
});
|
||||
@@ -3437,6 +3937,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var completed = JSON.parse(payloadText || 'null');
|
||||
if (completed && completed.output) assistantText = searchText(completed.output);
|
||||
} catch (_) {}
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
}
|
||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
try {
|
||||
@@ -3449,6 +3950,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
|
||||
}
|
||||
renderPageAiConversation();
|
||||
pageAiSetRunStatus('tool_calling', runId);
|
||||
}
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
@@ -3461,6 +3963,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
} catch (error) {
|
||||
pageAiSetRunStatus('failed');
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
@@ -3473,6 +3976,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
} finally {
|
||||
pageUiState.pageAiBusy = false;
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4039,6 +4543,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
var pageAiTab = closestAction(e.target, '[data-page-ai-tab]');
|
||||
if (pageAiTab) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat';
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
|
||||
if (pageAiProvider) {
|
||||
e.preventDefault();
|
||||
@@ -4047,6 +4560,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
|
||||
if (pageAiMemorySave) {
|
||||
e.preventDefault();
|
||||
void pageAiSaveProfileMemory(pageAiMemorySave.getAttribute('data-page-ai-memory-save') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSkillToggle = closestAction(e.target, '[data-page-ai-skill-toggle]');
|
||||
if (pageAiSkillToggle) {
|
||||
e.preventDefault();
|
||||
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
|
||||
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
|
||||
void pageAiToggleSkill(skillName, nextEnabled);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
@@ -4079,6 +4608,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
e.preventDefault();
|
||||
pageAiLoadSessions();
|
||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
return;
|
||||
}
|
||||
@@ -4281,7 +4811,34 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('input', function(event) {
|
||||
var skillSearch = closestAction(event.target, '[data-page-ai-skill-search]');
|
||||
if (skillSearch instanceof HTMLInputElement) {
|
||||
pageUiState.pageAiSkillQuery = skillSearch.value;
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
|
||||
if (memoryEditor instanceof HTMLTextAreaElement) {
|
||||
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
||||
if (['memory', 'user', 'soul'].indexOf(section) >= 0) {
|
||||
pageUiState.pageAiProfileMemoryDrafts[section] = memoryEditor.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
var pageAiProfileSelect = closestAction(event.target, '[data-page-ai-profile-select]');
|
||||
if (pageAiProfileSelect instanceof HTMLSelectElement) {
|
||||
void pageAiSwitchProfile(pageAiProfileSelect.value);
|
||||
return;
|
||||
}
|
||||
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
|
||||
if (pageAiContextSelect instanceof HTMLSelectElement) {
|
||||
pageAiSetContextScope(pageAiContextSelect.value);
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
|
||||
if (globalCheckbox instanceof HTMLInputElement) {
|
||||
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
|
||||
|
||||
@@ -2668,6 +2668,95 @@ body {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-statusbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(68px, auto) minmax(72px, auto) auto;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wolai-page-ai-statusitem {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
|
||||
.wolai-page-ai-statuslabel,
|
||||
.wolai-page-ai-profile-select span,
|
||||
.wolai-page-ai-context-select span,
|
||||
.wolai-page-ai-skill-search span,
|
||||
.wolai-page-ai-memory-scope,
|
||||
.wolai-page-ai-skill-source {
|
||||
display: block;
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-statusvalue {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-settings-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-topbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-profile-select,
|
||||
.wolai-page-ai-context-select,
|
||||
.wolai-page-ai-skill-search {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wolai-page-ai-profile-select select,
|
||||
.wolai-page-ai-context-select select,
|
||||
.wolai-page-ai-skill-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #FFF;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-inline-error {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(198, 80, 80, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #FFF5F5;
|
||||
color: #9F2D2D;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -2676,6 +2765,24 @@ body {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-panel-section {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-panel-section[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2731,6 +2838,122 @@ body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-grid {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-title,
|
||||
.wolai-page-ai-skill-name {
|
||||
color: #1B1C1C;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-editor {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 8px;
|
||||
background: #FAFAFA;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skills-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #5A5A5A;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
flex: 0 0 36px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #D8D6D1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-switch span {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #FFF;
|
||||
box-shadow: 0 1px 3px rgba(27, 28, 28, 0.16);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-switch.is-on {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-switch.is-on span {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.wolai-page-ai-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2828,6 +3051,14 @@ body {
|
||||
width: min(100vw - 24px, 420px);
|
||||
}
|
||||
|
||||
.wolai-page-ai-statusbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wolai-page-ai-topbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.wolai-public-pill,
|
||||
.wolai-breadcrumb-root,
|
||||
.wolai-breadcrumb-separator {
|
||||
|
||||
Reference in New Issue
Block a user