feat: complete project mentor feature with WSL path fix

- 新增 project_notes 数据层(T5):DB 迁移 + get/add 命令
- MCP 新增 append_project_note 工具,含 group 归属校验(T6)
- MentorPanel 底部展示 AI 笔记区域(T7)
- 后端全组巡检命令 get_group_mentor_report(T8)
- ManagePage 产品组卡片新增「巡检全组」按钮(T9)
- 启动健康扫描 + App.tsx 顶部告警 Banner(T10)
- 修复 WSL 项目路径检测:wsl_path 为 UNC 格式时
  用 unc_to_distro_and_linux() 转换后再调用 wsl 子命令
This commit is contained in:
lanrtop 2026-04-04 23:20:09 +09:00
parent eb346ae1fd
commit af88408669
8 changed files with 503 additions and 11 deletions

View File

@ -1,6 +1,6 @@
use crate::db::pool; use crate::db::pool;
use rusqlite::params; use rusqlite::params;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
// ── 返回结构 ────────────────────────────────────────────────────────────────── // ── 返回结构 ──────────────────────────────────────────────────────────────────
@ -423,3 +423,311 @@ fn build_mentor_markdown(
md md
} }
// ── 项目笔记 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectNote {
pub id: i64,
pub project_id: String,
pub source: String,
pub content: String,
pub created_at: String,
}
#[tauri::command]
pub fn get_project_notes(project_id: String) -> Result<Vec<ProjectNote>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, project_id, source, content, created_at
FROM project_notes
WHERE project_id = ?1
ORDER BY id DESC LIMIT 10",
)
.map_err(|e| e.to_string())?;
let notes = stmt
.query_map(params![project_id], |row| {
Ok(ProjectNote {
id: row.get(0)?,
project_id: row.get(1)?,
source: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(notes)
}
#[tauri::command]
pub fn add_project_note(
project_id: String,
content: String,
source: String,
) -> Result<(), String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, ?2, ?3)",
params![project_id, source, content],
)
.map_err(|e| e.to_string())?;
Ok(())
}
// ── 启动健康扫描 ──────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct StartupAlert {
pub project_name: String,
pub alert_type: String,
pub detail: String,
}
/// 应用启动时调用,扫描所有项目:
/// - stale_git超过 14 天无 git 提交
/// - blocked_tasks蓝图中有 🔴 blocked 任务
/// 结果写入 runtime_logscategory="startup_alert"),同时返回告警列表供前端展示
pub fn run_startup_health_scan() {
let conn = match pool().get() {
Ok(c) => c,
Err(_) => return,
};
// 清除上次启动的 startup_alert 记录,避免堆积
let _ = conn.execute(
"DELETE FROM runtime_logs WHERE category = 'startup_alert'",
[],
);
// 查询所有 workspace 的基本信息
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>)> = {
let mut stmt = match conn.prepare(
"SELECT pw.id, pp.name, pw.win_path, pw.wsl_path, pw.platform
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id",
) {
Ok(s) => s,
Err(_) => return,
};
stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
})
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
};
for (project_id, project_name, win_path, wsl_path, platform) in rows {
// 1. 检查 git 活跃度(超 14 天无提交)
// 直接获取距今天数None 表示路径不存在或无 git跳过不是"停滞"
if let Some(days) = get_days_since_last_commit(platform.as_deref(), win_path.as_deref(), wsl_path.as_deref(), &conn) {
if days > 14 {
// \x1f (ASCII Unit Separator) 作分隔符,避免项目名含 | 导致解析错位
crate::db::log_event(
"startup_alert",
"warn",
&format!("{}\x1fstale_git\x1f{} 天未提交", project_name, days),
Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)),
);
}
}
// 2. 检查蓝图 blocked 任务
let blueprint_path_opt = if platform.as_deref() == Some("wsl") {
wsl_path.as_deref().and_then(|lp| {
let distro = conn
.query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0))
.unwrap_or_else(|_| "Ubuntu".to_string());
let manifest_linux = format!("{}/.blueprint/manifest.yaml", lp);
let exists = std::process::Command::new("wsl")
.args(["-d", &distro, "--", "test", "-f", &manifest_linux])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if exists {
let inner = lp.trim_start_matches('/').replace('/', "\\");
Some(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}\\.blueprint", distro, inner)))
} else {
None
}
})
} else {
win_path.as_deref().map(|p| std::path::Path::new(p).join(".blueprint"))
};
if let Some(bp_dir) = blueprint_path_opt {
let blocked_count = count_blocked_tasks(&bp_dir);
if blocked_count > 0 {
crate::db::log_event(
"startup_alert",
"warn",
&format!("{}\x1fblocked_tasks\x1f{} 个任务阻塞", project_name, blocked_count),
Some(&format!(r#"{{"project_id":"{}"}}"#, project_id)),
);
}
}
}
}
/// 获取项目最后一次 git 提交距今天数
fn get_days_since_last_commit(
platform: Option<&str>,
win_path: Option<&str>,
wsl_path: Option<&str>,
conn: &rusqlite::Connection,
) -> Option<u64> {
let output = if platform == Some("wsl") {
let lp = wsl_path?;
let distro = conn
.query_row("SELECT value FROM settings WHERE key='wsl_distro'", [], |r| r.get::<_, String>(0))
.unwrap_or_else(|_| "Ubuntu".to_string());
std::process::Command::new("wsl")
.args(["-d", &distro, "--", "git", "-C", lp, "log", "-1", "--format=%ct"])
.stderr(std::process::Stdio::null())
.output()
.ok()?
} else {
let root = win_path?;
std::process::Command::new("git")
.args(["log", "-1", "--format=%ct"])
.current_dir(root)
.output()
.ok()?
};
if !output.status.success() {
return None;
}
let ts_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
let commit_ts: u64 = ts_str.parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Some((now.saturating_sub(commit_ts)) / 86400)
}
/// 统计 .blueprint/modules/ 下所有 .md 文件中 🔴 前缀任务卡的数量
fn count_blocked_tasks(blueprint_dir: &std::path::Path) -> usize {
let modules_dir = blueprint_dir.join("modules");
let entries = match std::fs::read_dir(&modules_dir) {
Ok(e) => e,
Err(_) => return 0,
};
let mut count = 0;
for entry in entries.filter_map(|e| e.ok()) {
if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Ok(content) = std::fs::read_to_string(entry.path()) {
count += content.lines().filter(|l| l.starts_with("### 🔴")).count();
}
}
count
}
#[tauri::command]
pub fn get_startup_alerts() -> Result<Vec<StartupAlert>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT message FROM runtime_logs
WHERE category = 'startup_alert' AND level = 'warn'
ORDER BY id DESC LIMIT 50",
)
.map_err(|e| e.to_string())?;
let alerts = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.filter_map(|msg| {
// 消息格式:"{project_name}\x1f{alert_type}\x1f{detail}"
// 使用 ASCII Unit Separator 避免项目名含 | 导致解析错位
let parts: Vec<&str> = msg.splitn(3, '\x1f').collect();
if parts.len() == 3 {
Some(StartupAlert {
project_name: parts[0].to_string(),
alert_type: parts[1].to_string(),
detail: parts[2].to_string(),
})
} else {
None
}
})
.collect();
Ok(alerts)
}
// ── 全组巡检 ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_group_mentor_report(group_id: String) -> Result<String, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 查询该产品组所有成员项目
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pgm.group_id = ?1
ORDER BY pp.name",
)
.map_err(|e| e.to_string())?;
let members: Vec<(String, String)> = stmt
.query_map(params![group_id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if members.is_empty() {
return Err("该产品组暂无成员项目".to_string());
}
let mut report = format!(
"# 全组巡检报告\n\n产品组 ID`{}`\n共 {} 个项目\n\n---\n\n",
group_id,
members.len()
);
for (project_id, project_name) in &members {
report.push_str(&format!("## {}\n\n", project_name));
match get_mentor_context(project_id.clone()) {
Ok(ctx) => {
// 去掉一级标题,同时把 ## 章节降级为 ###,避免与项目名 ## 同级混淆
let body = ctx
.mentor_markdown
.lines()
.skip_while(|l| l.starts_with("# "))
.map(|l| {
if l.starts_with("## ") {
format!("#{}", l)
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
report.push_str(body.trim_start());
}
Err(e) => {
report.push_str(&format!("_获取导师上下文失败{}_\n", e));
}
}
report.push_str("\n\n---\n\n");
}
report.push_str("请根据以上所有项目的信息,对这个产品组的整体状态给出综合分析和下一步建议。\n");
Ok(report)
}

View File

@ -401,6 +401,17 @@ fn migrate(conn: &rusqlite::Connection) {
context TEXT context TEXT
); );
").expect("create runtime_logs table"); ").expect("create runtime_logs table");
// AI 笔记Claude 通过 MCP 或用户手动写入的分析结论)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'mcp',
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
").expect("create project_notes table");
} }
#[cfg(test)] #[cfg(test)]

View File

@ -5,7 +5,7 @@ mod mcp_server;
mod models; mod models;
use git2; use git2;
use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::*, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*}; use commands::{activity::*, blueprint::*, canvas::*, cicd::*, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@ -45,6 +45,11 @@ pub fn run() {
let mcp_port = mcp_server::read_port(); let mcp_port = mcp_server::read_port();
tauri::async_runtime::spawn(mcp_server::start(mcp_port)); tauri::async_runtime::spawn(mcp_server::start(mcp_port));
// ── 启动健康扫描(异步,不阻塞窗口渲染)────────────────
tauri::async_runtime::spawn(async {
tokio::task::spawn_blocking(run_startup_health_scan).await.ok();
});
// ── 系统托盘 ────────────────────────────────────────────── // ── 系统托盘 ──────────────────────────────────────────────
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "退出炼境", true, None::<&str>)?;
@ -232,6 +237,10 @@ pub fn run() {
save_canvas_state, save_canvas_state,
// mentor // mentor
get_mentor_context, get_mentor_context,
get_project_notes,
add_project_note,
get_group_mentor_report,
get_startup_alerts,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -162,6 +162,24 @@ fn tools_list_result() -> Value {
}, },
"required": ["project_id"] "required": ["project_id"]
} }
},
{
"name": "append_project_note",
"description": "将 Claude 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"content": {
"type": "string",
"description": "笔记内容,支持 Markdown。建议包含核心观点、发现的问题、下一步建议。"
}
},
"required": ["project_id", "content"]
}
} }
] ]
}) })
@ -200,6 +218,11 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(&gid, pid) get_project_mentor_context(&gid, pid)
} }
"append_project_note" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(&gid, pid, content)
}
_ => Err(format!("未知工具: {}", tool_name)), _ => Err(format!("未知工具: {}", tool_name)),
}) })
.await; .await;
@ -454,3 +477,28 @@ fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result<String
.map_err(|e| format!("获取导师上下文失败: {e}"))?; .map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown) Ok(ctx.mentor_markdown)
} }
fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result<String, String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 校验 project_id 属于该 group
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2",
[group_id, project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id));
}
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
rusqlite::params![project_id, content],
)
.map_err(|e| e.to_string())?;
Ok("笔记已保存 ✅".to_string())
}

View File

@ -10,7 +10,7 @@ import { HealthPage } from "./components/health/HealthPage";
import { Sidebar, type Page } from "./components/layout/Sidebar"; import { Sidebar, type Page } from "./components/layout/Sidebar";
import { Toast } from "./components/ui/Toast"; import { Toast } from "./components/ui/Toast";
import LoginPage from "./components/auth/LoginPage"; import LoginPage from "./components/auth/LoginPage";
import { githubGetAccount, githubLogout, type GitAccount } from "./lib/commands"; import { githubGetAccount, githubLogout, getStartupAlerts, type GitAccount, type StartupAlert } from "./lib/commands";
import { useSpacesSync } from "./hooks/useSpacesSync"; import { useSpacesSync } from "./hooks/useSpacesSync";
import { useUIStore } from "./store/ui"; import { useUIStore } from "./store/ui";
import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { check as checkUpdate } from "@tauri-apps/plugin-updater";
@ -18,6 +18,8 @@ import { check as checkUpdate } from "@tauri-apps/plugin-updater";
export default function App() { export default function App() {
const [page, setPage] = useState<Page>("overview"); const [page, setPage] = useState<Page>("overview");
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined); const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
const [alerts, setAlerts] = useState<StartupAlert[]>([]);
const [alertsDismissed, setAlertsDismissed] = useState(false);
const qc = useQueryClient(); const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast); const showToast = useUIStore((s) => s.showToast);
@ -36,6 +38,17 @@ export default function App() {
.catch(() => {}); // 静默失败,不打扰用户 .catch(() => {}); // 静默失败,不打扰用户
}, []); }, []);
// 启动健康扫描结果(延迟 2s 等后端扫描完成)
useEffect(() => {
if (account == null) return;
const timer = setTimeout(() => {
getStartupAlerts()
.then((list) => { if (list.length > 0) setAlerts(list); })
.catch(() => {});
}, 2000);
return () => clearTimeout(timer);
}, [account]);
const handleLogout = async () => { const handleLogout = async () => {
await githubLogout(); await githubLogout();
qc.clear(); qc.clear();
@ -64,13 +77,32 @@ export default function App() {
onPageChange={setPage} onPageChange={setPage}
/> />
<main className="flex-1 overflow-auto bg-gray-50"> <main className="flex-1 overflow-auto bg-gray-50 flex flex-col min-h-0">
{page === "overview" && <OverviewPage />} {alerts.length > 0 && !alertsDismissed && (
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />} <div className="shrink-0 bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-start gap-3">
{page === "devtools" && <DevToolsPage />} <span className="text-amber-500 shrink-0 mt-0.5"></span>
{page === "proxy" && <ProxyPage />} <div className="flex-1 min-w-0">
{page === "settings" && <SettingsPage />} <span className="text-xs font-semibold text-amber-800"></span>
{page === "health" && <HealthPage />} <span className="text-xs text-amber-700 ml-1">
{alerts.slice(0, 3).map((a) => `${a.project_name}${a.detail}`).join("、")}
{alerts.length > 3 && `,等 ${alerts.length - 3} 个项目`}
</span>
</div>
<button
onClick={() => setAlertsDismissed(true)}
className="shrink-0 text-amber-400 hover:text-amber-600 text-sm leading-none"
aria-label="关闭"
>×</button>
</div>
)}
<div className="flex-1 overflow-auto min-h-0">
{page === "overview" && <OverviewPage />}
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />}
{page === "devtools" && <DevToolsPage />}
{page === "proxy" && <ProxyPage />}
{page === "settings" && <SettingsPage />}
{page === "health" && <HealthPage />}
</div>
</main> </main>
<ProjectModal /> <ProjectModal />

View File

@ -4,6 +4,7 @@ import {
deleteProject, getGroups, getGroupMaps, getProjects, getSpaces, deleteProject, getGroups, getGroupMaps, getProjects, getSpaces,
deleteGroup, addGroupMember, removeGroupMember, deleteGroup, addGroupMember, removeGroupMember,
createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate, createGroup, updateGroup, reorderGroups, listTemplates, deleteTemplate,
getGroupMentorReport,
type ProductGroup, type Project, type ProjectTemplate, type ProductGroup, type Project, type ProjectTemplate,
} from "../../lib/commands"; } from "../../lib/commands";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
@ -211,6 +212,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined); const [groupModal, setGroupModal] = useState<ProductGroup | null | undefined>(undefined);
const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null); const [memberTarget, setMemberTarget] = useState<ProductGroup | null>(null);
const [delGroup, setDelGroup] = useState<string | null>(null); const [delGroup, setDelGroup] = useState<string | null>(null);
const [inspecting, setInspecting] = useState<Set<string>>(new Set());
const handleInspect = async (groupId: string) => {
setInspecting((prev) => new Set(prev).add(groupId));
try {
const report = await getGroupMentorReport(groupId);
await navigator.clipboard.writeText(report);
showToast("已复制,可粘贴给 Claude 进行全组分析");
} catch (e) {
showToast(`❌ 巡检失败: ${e}`);
} finally {
setInspecting((prev) => { const s = new Set(prev); s.delete(groupId); return s; });
}
};
const { data: groups = [] } = useQuery({ const { data: groups = [] } = useQuery({
queryKey: ["groups", spaceId], queryKey: ["groups", spaceId],
@ -281,6 +296,20 @@ function GroupsTab({ spaceId }: { spaceId?: string }) {
)} )}
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<button
onClick={() => handleInspect(g.id)}
disabled={inspecting.has(g.id)}
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-xs text-indigo-600 hover:bg-indigo-50 transition-colors disabled:opacity-50 flex items-center gap-1"
title="聚合全组导师报告并复制到剪贴板"
>
{inspecting.has(g.id) ? (
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
) : "🔍"}
</button>
<button onClick={() => setMemberTarget(g)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors"> <button onClick={() => setMemberTarget(g)} className="px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-100 transition-colors">
+ +
</button> </button>

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMentorContext, type MentorContext } from "../../lib/commands"; import { getMentorContext, getProjectNotes, type MentorContext, type ProjectNote } from "../../lib/commands";
interface Props { interface Props {
projectId: string; projectId: string;
@ -18,6 +18,13 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
retry: false, retry: false,
}); });
const { data: notes = [] } = useQuery<ProjectNote[]>({
queryKey: ["projectNotes", projectId],
queryFn: () => getProjectNotes(projectId),
staleTime: 30000,
retry: false,
});
const handleCopy = () => { const handleCopy = () => {
if (!ctx) return; if (!ctx) return;
navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {}); navigator.clipboard.writeText(ctx.mentor_markdown).catch(() => {});
@ -143,6 +150,28 @@ export function MentorPanel({ projectId, projectName, onClose }: Props) {
</ul> </ul>
)} )}
</section> </section>
{/* AI 笔记 */}
<section>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">AI </h3>
{notes.length === 0 ? (
<p className="text-xs text-gray-400">Claude MCP </p>
) : (
<ul className="flex flex-col gap-2 max-h-48 overflow-y-auto">
{notes.map((note) => (
<li key={note.id} className="flex flex-col gap-1 rounded-lg bg-indigo-50 border border-indigo-100 px-3 py-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-indigo-400 uppercase tracking-wide">
{note.source === "mcp" ? "Claude · MCP" : "手动"}
</span>
<span className="text-[10px] text-gray-400 font-mono">{note.created_at.slice(0, 16)}</span>
</div>
<p className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{note.content}</p>
</li>
))}
</ul>
)}
</section>
</div> </div>
)} )}
</div> </div>

View File

@ -1094,3 +1094,29 @@ export interface MentorContext {
export const getMentorContext = (projectId: string) => export const getMentorContext = (projectId: string) =>
invoke<MentorContext>("get_mentor_context", { projectId }); invoke<MentorContext>("get_mentor_context", { projectId });
export const getGroupMentorReport = (groupId: string) =>
invoke<string>("get_group_mentor_report", { groupId });
export interface StartupAlert {
project_name: string;
alert_type: string;
detail: string;
}
export const getStartupAlerts = () =>
invoke<StartupAlert[]>("get_startup_alerts");
export interface ProjectNote {
id: number;
project_id: string;
source: string;
content: string;
created_at: string;
}
export const getProjectNotes = (projectId: string) =>
invoke<ProjectNote[]>("get_project_notes", { projectId });
export const addProjectNote = (projectId: string, content: string, source: string = "manual") =>
invoke<void>("add_project_note", { projectId, content, source });