feat(chat): panel Echo + gx-cli MCP server con tools tipadas
Anade panel "Echo" — copiloto OSINT que invoca claude -p con un MCP server propio (gx-cli) exponiendo el grafo como tools tipadas: info, node_*, rel_*, table_*, enricher_*, query. Cambios: - chat.cpp/h: panel UI dockeable con history, raw stream-json toggle, spawn de claude -p con system prompt OSINT, ChatMessage con USER/ ASSISTANT/TOOL_USE/TOOL_RESULT/SYSTEM/ERROR_MSG, escritura de mcp.json con paths Linux para WSL en Windows. - gx-cli: binario MCP standalone que valida cada tool, abre operations.db en RW, escribe agent_mutations counter para que el viewport detecte cambios en vivo. - CMakeLists.txt: anade chat.cpp al target. - views.h: panel_chat boolean en AppState. - main.cpp: integracion del panel Chat (rename a Echo en menubar + init), refresh de contexto al cambiar operations.db, drain de cola agent_jobs tras enricher_run. Mensajes del panel renderizan con fn_ui::selectable_text_wrapped_force (wrap forzado + seleccion) para que URLs/JSON largos no se clippeen y permitan copy/paste. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ add_imgui_app(graph_explorer
|
||||
tableview.cpp
|
||||
jobs.cpp
|
||||
enrichers.cpp
|
||||
chat.cpp
|
||||
# --- viz ---
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Panel Chat — agente Claude (claude -p) con tool-use sobre operations.db
|
||||
// via gx-cli. Subprocess persistente bidireccional (stdin/stdout JSON-lines).
|
||||
// El usuario escribe, el hilo lector parsea stream-json y va emitiendo
|
||||
// fragmentos al historial. gx-cli muta operations.db; el contador
|
||||
// agent_mutations en graph_explorer.db dispara reload del viewport.
|
||||
|
||||
namespace ge {
|
||||
|
||||
// Inicia el subprocess claude -p (lazy: hasta el primer mensaje no se
|
||||
// arranca). Setea env vars GX_OPS_DB / GX_APP_DB / GX_APP_DIR. Devuelve
|
||||
// false si claude no esta disponible (o, en Windows, wsl no esta).
|
||||
bool chat_init(const char* ops_db_path,
|
||||
const char* app_db_path,
|
||||
const char* app_dir);
|
||||
|
||||
// Si la ops_db cambia (proyecto switch), refresca env del subprocess
|
||||
// matandolo y dejando que el siguiente send lo reabra.
|
||||
void chat_set_ops_db(const char* ops_db_path);
|
||||
|
||||
// Envia un mensaje del usuario al agente. Si el subprocess no esta vivo,
|
||||
// lo arranca primero. No bloquea — el resultado llega via chat_render
|
||||
// al ir vaciando la cola del hilo lector.
|
||||
void chat_send(const char* user_text);
|
||||
|
||||
// Renderiza el panel ImGui (titulo "Chat"). Drena cola de mensajes del
|
||||
// hilo lector. `panel_open` es bound al close button.
|
||||
void chat_render(bool* panel_open);
|
||||
|
||||
// Cierra el subprocess y libera recursos. Llamar en shutdown.
|
||||
void chat_shutdown();
|
||||
|
||||
// Counter de mutaciones (lee tabla agent_mutations en app_db). Se llama
|
||||
// desde main.cpp cada frame para detectar si gx-cli muto algo y disparar
|
||||
// reload del grafo. Devuelve 0 si la tabla no existe todavia.
|
||||
int chat_mutations_counter();
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Logging con tags
|
||||
//
|
||||
// Todas las trazas del subsistema chat van a `<app_dir>/chat.log` ademas de
|
||||
// stderr. Cada linea tiene formato:
|
||||
//
|
||||
// 2026-05-01T18:35:50.853Z [chat:detect] mensaje
|
||||
//
|
||||
// Tags usados (grep amigable):
|
||||
// detect deteccion de claude/wsl al arrancar
|
||||
// env env vars seteadas para el subprocess
|
||||
// spawn argv completo + cwd al lanzar el subprocess
|
||||
// io operaciones sobre los pipes (lectura/escritura/EOF)
|
||||
// parse eventos JSON parseados desde stream-json
|
||||
// tools tool_use detectados, comandos Bash invocados
|
||||
// mut cambios detectados via agent_mutations.counter
|
||||
// error fallos y exit codes
|
||||
// ----------------------------------------------------------------------------
|
||||
void chat_log(const char* tag, const char* fmt, ...);
|
||||
|
||||
// Devuelve el path absoluto del fichero de log (vacio si no inicializado).
|
||||
const char* chat_log_path();
|
||||
|
||||
} // namespace ge
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "core/button.h"
|
||||
#include "core/tokens.h"
|
||||
#include "core/icons_tabler.h"
|
||||
#include "core/layout_storage.h"
|
||||
#include "core/layouts_menu.h"
|
||||
|
||||
#include "viz/graph_types.h"
|
||||
#include "viz/graph_viewport.h"
|
||||
@@ -27,6 +29,7 @@
|
||||
#include "project_manager.h"
|
||||
#include "jobs.h"
|
||||
#include "enrichers.h"
|
||||
#include "chat.h"
|
||||
|
||||
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
|
||||
|
||||
@@ -73,6 +76,98 @@ static std::string g_layout_db_path; // ruta de graph_explorer.db
|
||||
static ForceLayoutGPU* g_gpu_ctx = nullptr;
|
||||
static bool g_gpu_dirty = true;
|
||||
|
||||
// Layout storage (menu Layouts) — guardado/cargado de layouts ImGui en
|
||||
// graph_explorer.db tabla imgui_layouts.
|
||||
static fn_ui::LayoutStorage* g_layout_storage = nullptr;
|
||||
static fn_ui::LayoutCallbacks g_layout_cb{};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Persistencia de paneles abiertos/cerrados
|
||||
//
|
||||
// Los toggles `panel_chat`, `panel_jobs`, etc. viven en AppState (RAM). Sin
|
||||
// persistencia, al reabrir la app vuelven a sus defaults — el usuario tiene
|
||||
// que reabrir manualmente cada panel cada vez.
|
||||
//
|
||||
// Tabla `panel_state(name TEXT PK, open INT, updated_at INT)` en la misma
|
||||
// graph_explorer.db. load al arrancar, save al cerrar.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static void panel_state_ensure_table(sqlite3* db) {
|
||||
sqlite3_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS panel_state ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" open INTEGER NOT NULL,"
|
||||
" updated_at INTEGER NOT NULL)",
|
||||
nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
static void panel_state_load_db(const std::string& db_path,
|
||||
fn_ui::PanelToggle* panels, size_t n) {
|
||||
if (db_path.empty()) return;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path.c_str(), &db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
|
||||
nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return;
|
||||
}
|
||||
panel_state_ensure_table(db);
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT open FROM panel_state WHERE name = ?",
|
||||
-1, &st, nullptr) == SQLITE_OK) {
|
||||
int restored = 0;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
if (!panels[i].open || !panels[i].label) continue;
|
||||
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
*panels[i].open = (sqlite3_column_int(st, 0) != 0);
|
||||
++restored;
|
||||
}
|
||||
sqlite3_reset(st);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] panel_state: restored %d/%zu panels\n",
|
||||
restored, n);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
static void panel_state_save_db(const std::string& db_path,
|
||||
const fn_ui::PanelToggle* panels, size_t n) {
|
||||
if (db_path.empty()) return;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path.c_str(), &db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
|
||||
nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return;
|
||||
}
|
||||
panel_state_ensure_table(db);
|
||||
sqlite3_stmt* st = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO panel_state(name, open, updated_at) "
|
||||
"VALUES (?, ?, strftime('%s','now')) "
|
||||
"ON CONFLICT(name) DO UPDATE SET "
|
||||
" open = excluded.open, "
|
||||
" updated_at = excluded.updated_at";
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) {
|
||||
int saved = 0;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
if (!panels[i].open || !panels[i].label) continue;
|
||||
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int (st, 2, *panels[i].open ? 1 : 0);
|
||||
if (sqlite3_step(st) == SQLITE_DONE) ++saved;
|
||||
sqlite3_reset(st);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] panel_state: saved %d/%zu panels\n", saved, n);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
// Icon atlas (de types.yaml)
|
||||
static IconAtlas* g_atlas = nullptr;
|
||||
static bool g_atlas_bound = false;
|
||||
@@ -142,86 +237,139 @@ static int layout_first_placed_neighbor(const GraphData& g, int node_idx) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Coloca todos los nodos del grafo que esten en (0,0) cerca de su primer
|
||||
// vecino con posicion conocida, eligiendo el primer slot angular libre
|
||||
// dentro de un radio creciente. Sin vecinos colocados → aparca en columna
|
||||
// a la derecha del bbox actual.
|
||||
static void place_orphans_near_neighbors(GraphData& g, float min_dist) {
|
||||
if (g.node_count == 0) return;
|
||||
const float radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
|
||||
const int n_radii = (int)(sizeof(radii) / sizeof(radii[0]));
|
||||
// Encuentra una posicion sin colision para `self_idx` haciendo un barrido
|
||||
// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve
|
||||
// true y escribe (out_x, out_y); false si no hay hueco en los radios
|
||||
// disponibles. `seed` se usa para jitter deterministico (ej: user_data).
|
||||
static bool find_collision_free_slot(const GraphData& g, int self_idx,
|
||||
float cx, float cy, float min_dist,
|
||||
uint64_t seed,
|
||||
const float* radii, int n_radii,
|
||||
float* out_x, float* out_y)
|
||||
{
|
||||
const int slots = 12;
|
||||
const float two_pi = 6.28318530718f;
|
||||
const float slot_arc = two_pi / slots;
|
||||
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * slot_arc;
|
||||
|
||||
// Recompute bbox solo de los nodos colocados (para park_in_column).
|
||||
float bbox_max_x = 0.0f, bbox_min_y = 0.0f, bbox_max_y = 0.0f;
|
||||
// Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto.
|
||||
if (layout_no_collision(g, self_idx, cx, cy, min_dist)) {
|
||||
*out_x = cx; *out_y = cy;
|
||||
return true;
|
||||
}
|
||||
for (int ri = 0; ri < n_radii; ++ri) {
|
||||
float r = radii[ri];
|
||||
for (int s = 0; s < slots; ++s) {
|
||||
float a = jitter + s * slot_arc;
|
||||
float px = cx + r * std::cos(a);
|
||||
float py = cy + r * std::sin(a);
|
||||
if (layout_no_collision(g, self_idx, px, py, min_dist)) {
|
||||
*out_x = px; *out_y = py;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coloca todos los nodos del grafo que esten en (0,0):
|
||||
// 1. Si tiene un vecino con posicion → ring placement junto al vecino.
|
||||
// 2. Sin vecino: si `use_camera` → ring placement alrededor de la camara
|
||||
// (cam_cx, cam_cy) con un radio inicial proporcional al zoom — asi
|
||||
// los nodos creados por el agente aparecen DENTRO de la vista actual,
|
||||
// sin solapar con lo que ya hay en pantalla.
|
||||
// 3. Sin vecino y sin camera → fallback legacy: columna a la derecha del
|
||||
// bbox (usado en first-load donde el viewport todavia no se ha hecho
|
||||
// fit).
|
||||
static void place_orphans_near_neighbors(GraphData& g, float min_dist,
|
||||
bool use_camera = false,
|
||||
float cam_cx = 0.0f,
|
||||
float cam_cy = 0.0f,
|
||||
float cam_radius = 120.0f) {
|
||||
if (g.node_count == 0) return;
|
||||
const float neighbor_radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
|
||||
const int n_neighbor_radii = (int)(sizeof(neighbor_radii) /
|
||||
sizeof(neighbor_radii[0]));
|
||||
|
||||
// Anillos crecientes alrededor de la camara — empieza pequeno (cam_radius
|
||||
// base ~viewport/zoom) para mantener los nuevos cerca del foco visual.
|
||||
float cam_radii[6];
|
||||
for (int i = 0; i < 6; ++i) cam_radii[i] = cam_radius * (1.0f + i * 0.6f);
|
||||
|
||||
// Bbox para fallback legacy (columna lateral) cuando use_camera=false.
|
||||
float bbox_max_x = 0.0f, bbox_min_y = 0.0f;
|
||||
bool bbox_init = false;
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
const GraphNode& n = g.nodes[i];
|
||||
if (n.x == 0.0f && n.y == 0.0f) continue;
|
||||
if (!bbox_init) {
|
||||
bbox_max_x = n.x; bbox_min_y = n.y; bbox_max_y = n.y;
|
||||
bbox_init = true;
|
||||
} else {
|
||||
if (n.x > bbox_max_x) bbox_max_x = n.x;
|
||||
if (n.y < bbox_min_y) bbox_min_y = n.y;
|
||||
if (n.y > bbox_max_y) bbox_max_y = n.y;
|
||||
if (!use_camera) {
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
const GraphNode& n = g.nodes[i];
|
||||
if (n.x == 0.0f && n.y == 0.0f) continue;
|
||||
if (!bbox_init) {
|
||||
bbox_max_x = n.x; bbox_min_y = n.y;
|
||||
bbox_init = true;
|
||||
} else {
|
||||
if (n.x > bbox_max_x) bbox_max_x = n.x;
|
||||
if (n.y < bbox_min_y) bbox_min_y = n.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f;
|
||||
float park_y = bbox_init ? bbox_min_y : 0.0f;
|
||||
int park_n = 0;
|
||||
|
||||
int placed = 0, parked = 0;
|
||||
int placed_neighbor = 0, placed_camera = 0, parked = 0;
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
GraphNode& n = g.nodes[i];
|
||||
if (n.x != 0.0f || n.y != 0.0f) continue;
|
||||
|
||||
int parent = layout_first_placed_neighbor(g, i);
|
||||
if (parent < 0) {
|
||||
// Sin vecino colocado: aparca en columna lateral, separados
|
||||
// verticalmente por min_dist. Si tampoco hay bbox (grafo
|
||||
// recien creado), columna en (0, 0) hacia abajo.
|
||||
n.x = park_x;
|
||||
n.y = park_y + park_n * min_dist;
|
||||
n.vx = 0.0f; n.vy = 0.0f;
|
||||
++park_n; ++parked;
|
||||
if (parent >= 0) {
|
||||
float ox, oy;
|
||||
if (find_collision_free_slot(
|
||||
g, i, g.nodes[parent].x, g.nodes[parent].y,
|
||||
min_dist, n.user_data,
|
||||
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
|
||||
n.x = ox; n.y = oy;
|
||||
} else {
|
||||
// Acepta solape como ultimo recurso.
|
||||
n.x = g.nodes[parent].x + neighbor_radii[n_neighbor_radii - 1];
|
||||
n.y = g.nodes[parent].y;
|
||||
}
|
||||
n.vx = n.vy = 0.0f;
|
||||
++placed_neighbor;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Jitter deterministico por user_data → dos huerfanos del mismo
|
||||
// padre eligen ciclos diferentes y no se solapan.
|
||||
float jitter = ((float)((n.user_data >> 16) & 0xFF) / 255.0f) * slot_arc;
|
||||
|
||||
bool placed_ok = false;
|
||||
for (int ri = 0; ri < n_radii && !placed_ok; ++ri) {
|
||||
float r = radii[ri];
|
||||
for (int s = 0; s < slots && !placed_ok; ++s) {
|
||||
float a = jitter + s * slot_arc;
|
||||
float cx = g.nodes[parent].x + r * std::cos(a);
|
||||
float cy = g.nodes[parent].y + r * std::sin(a);
|
||||
if (layout_no_collision(g, i, cx, cy, min_dist)) {
|
||||
n.x = cx; n.y = cy;
|
||||
n.vx = 0.0f; n.vy = 0.0f;
|
||||
placed_ok = true;
|
||||
}
|
||||
if (use_camera) {
|
||||
// Sin vecino → colocar dentro de la camara con ring placement.
|
||||
float ox, oy;
|
||||
if (find_collision_free_slot(
|
||||
g, i, cam_cx, cam_cy, min_dist, n.user_data,
|
||||
cam_radii, 6, &ox, &oy)) {
|
||||
n.x = ox; n.y = oy;
|
||||
} else {
|
||||
// Anillo amplio aceptando solape.
|
||||
float two_pi = 6.28318530718f;
|
||||
float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi;
|
||||
float r = cam_radii[5];
|
||||
n.x = cam_cx + std::cos(a) * r;
|
||||
n.y = cam_cy + std::sin(a) * r;
|
||||
}
|
||||
n.vx = n.vy = 0.0f;
|
||||
++placed_camera;
|
||||
continue;
|
||||
}
|
||||
if (!placed_ok) {
|
||||
// Fallback: pone en el ultimo radio + slot 0 (acepta solape).
|
||||
float r = radii[n_radii - 1];
|
||||
n.x = g.nodes[parent].x + r * std::cos(jitter);
|
||||
n.y = g.nodes[parent].y + r * std::sin(jitter);
|
||||
n.vx = 0.0f; n.vy = 0.0f;
|
||||
}
|
||||
++placed;
|
||||
|
||||
// Legacy: columna lateral (fuera de cam — usado en first_load).
|
||||
n.x = park_x;
|
||||
n.y = park_y + park_n * min_dist;
|
||||
n.vx = n.vy = 0.0f;
|
||||
++park_n; ++parked;
|
||||
}
|
||||
|
||||
if (placed > 0 || parked > 0) {
|
||||
if (placed_neighbor || placed_camera || parked) {
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] placed %d orphans near neighbors, %d parked in column\n",
|
||||
placed, parked);
|
||||
"[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n",
|
||||
placed_neighbor, placed_camera, parked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,35 +572,37 @@ static bool load_input(bool first_load) {
|
||||
g_viewport.layout_running = false;
|
||||
g_viewport.layout_energy = 0.0f;
|
||||
|
||||
// Posicionar nodos en primera carga: si todos tienen (x,y)=0, aplicar
|
||||
// layout circular como arranque. En reloads NO — los huerfanos los
|
||||
// resuelve `place_orphans_near_neighbors` despues de layout_store_load
|
||||
// (issue 0031).
|
||||
if (first_load) {
|
||||
int zero_pos = 0;
|
||||
for (int i = 0; i < g_graph.node_count; ++i) {
|
||||
if (g_graph.nodes[i].x == 0.0f && g_graph.nodes[i].y == 0.0f) ++zero_pos;
|
||||
}
|
||||
if (zero_pos == g_graph.node_count) {
|
||||
graph::layout_circular(g_graph, 200.0f);
|
||||
}
|
||||
}
|
||||
g_graph.update_bounds();
|
||||
|
||||
// Indice user_data -> sql id (para CRUD desde menu contextual).
|
||||
ge::entity_index_build(g_input.uri, &g_idx);
|
||||
g_app.input_db_path = g_input.uri ? g_input.uri : "";
|
||||
|
||||
// issue 0026 — apunta el JobRunner a la nueva operations.db.
|
||||
if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
|
||||
// Chat agent — refrescar contexto de la nueva operations.db.
|
||||
if (g_input.uri) ge::chat_set_ops_db(g_input.uri);
|
||||
|
||||
// Cargar posiciones guardadas para este graph_hash
|
||||
// Cargar posiciones guardadas para este graph_hash. Ahora ANTES del
|
||||
// bootstrap circular: si tenemos posiciones guardadas las respetamos;
|
||||
// solo aplicamos circular si NO hay nada guardado en primera carga.
|
||||
g_graph_hash = ge::compute_graph_hash(g_input.uri);
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
if (restored > 0) {
|
||||
std::fprintf(stdout, "[graph_explorer] restored %d node positions from layout store\n", restored);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] restored %d node positions from layout store\n",
|
||||
restored);
|
||||
}
|
||||
|
||||
// Bootstrap circular SOLO si no se restauro nada en primera carga (ej:
|
||||
// primer arranque tras crear el proyecto, o tras `Reset layout`). Si
|
||||
// restored>0 los nodos cargados ya tienen posicion; los nuevos sin
|
||||
// posicion guardada los colocara place_orphans_near_neighbors.
|
||||
if (first_load && restored == 0 && g_graph.node_count > 0) {
|
||||
graph::layout_circular(g_graph, 200.0f);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] bootstrap layout_circular (no saved positions)\n");
|
||||
}
|
||||
g_graph.update_bounds();
|
||||
|
||||
// Huerfanos (nodos sin posicion guardada): halo placement junto a su
|
||||
// primer vecino con coordenadas conocidas (issue 0031). En primera carga
|
||||
// tambien aplica — si layout_circular ya los puso en circulo, no entran
|
||||
@@ -754,11 +904,23 @@ static fn_ui::PanelToggle g_panels[] = {
|
||||
{"Types", nullptr, &g_app.panel_type_editor},
|
||||
{"Table", nullptr, &g_app.panel_table},
|
||||
{"Jobs", nullptr, &g_app.panel_jobs},
|
||||
{"Echo", nullptr, &g_app.panel_chat},
|
||||
};
|
||||
|
||||
static void render() {
|
||||
update_fps();
|
||||
|
||||
// Aplicar layout pendiente (si el usuario seleccionó uno del menu Layouts).
|
||||
// Debe ir antes de crear ventanas — LoadIniSettingsFromMemory afecta a las
|
||||
// posiciones que se calculan a continuación.
|
||||
if (g_layout_storage) {
|
||||
std::string applied = fn_ui::layout_storage_apply_pending(g_layout_storage);
|
||||
if (!applied.empty()) {
|
||||
std::fprintf(stdout, "[graph_explorer] layout aplicado: %s\n",
|
||||
applied.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].
|
||||
|
||||
if (!g_loaded) {
|
||||
@@ -848,6 +1010,81 @@ static void render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Chat agent — gx-cli toca .mutations.marker tras cada mutacion.
|
||||
// Polleamos su mtime cada N frames; si cambia, recargamos el grafo.
|
||||
// (Antes usaba un contador en agent_mutations.SQLite, pero WAL falla
|
||||
// cross-NTFS<->9p cuando el .exe Windows tiene la BD abierta.)
|
||||
{
|
||||
static int s_last_mut = -1; // -1 = primera lectura no hecha
|
||||
static int s_throttle = 0;
|
||||
if (++s_throttle >= 8) {
|
||||
s_throttle = 0;
|
||||
int m = ge::chat_mutations_counter();
|
||||
if (s_last_mut == -1) {
|
||||
// Primera lectura: solo memorizar, sin disparar reload.
|
||||
s_last_mut = m;
|
||||
} else if (m != s_last_mut) {
|
||||
ge::chat_log("mut",
|
||||
"marker mtime cambio %d -> %d, disparando reload",
|
||||
s_last_mut, m);
|
||||
s_last_mut = m;
|
||||
g_app.want_reload = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat agent — drena cola agent_jobs (gx-cli enricher run) e invoca
|
||||
// jobs_submit() para que el worker pool corriendo en C++ haga el trabajo.
|
||||
if (!g_layout_db_path.empty()) {
|
||||
sqlite3* adb = nullptr;
|
||||
if (sqlite3_open_v2(g_layout_db_path.c_str(), &adb,
|
||||
SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK) {
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(adb,
|
||||
"SELECT id, enricher_id, node_id, node_name, params_json "
|
||||
"FROM agent_jobs ORDER BY created_at LIMIT 8",
|
||||
-1, &st, nullptr) == SQLITE_OK) {
|
||||
std::vector<std::string> ids_to_drop;
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const char* req_id = (const char*)sqlite3_column_text(st, 0);
|
||||
const char* enr_id = (const char*)sqlite3_column_text(st, 1);
|
||||
const char* node = (const char*)sqlite3_column_text(st, 2);
|
||||
const char* nname = (const char*)sqlite3_column_text(st, 3);
|
||||
const char* params = (const char*)sqlite3_column_text(st, 4);
|
||||
char job_id[64];
|
||||
if (ge::jobs_submit(enr_id ? enr_id : "",
|
||||
node ? node : "",
|
||||
nname ? nname : "",
|
||||
params ? params : "{}",
|
||||
job_id, sizeof(job_id))) {
|
||||
std::fprintf(stdout,
|
||||
"[chat] queued enricher=%s node=%s as %s (req=%s)\n",
|
||||
enr_id ? enr_id : "", node ? node : "", job_id,
|
||||
req_id ? req_id : "");
|
||||
if (req_id) ids_to_drop.push_back(req_id);
|
||||
g_app.panel_jobs = true;
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"[chat] jobs_submit failed (req=%s enricher=%s)\n",
|
||||
req_id ? req_id : "", enr_id ? enr_id : "");
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
for (auto& id : ids_to_drop) {
|
||||
sqlite3_stmt* d = nullptr;
|
||||
if (sqlite3_prepare_v2(adb,
|
||||
"DELETE FROM agent_jobs WHERE id = ?",
|
||||
-1, &d, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(d, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(d);
|
||||
sqlite3_finalize(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_close(adb);
|
||||
}
|
||||
}
|
||||
|
||||
// Triggers desde la toolbar
|
||||
if (g_app.want_fit) {
|
||||
graph_viewport_fit(g_graph, g_viewport);
|
||||
@@ -867,20 +1104,37 @@ static void render() {
|
||||
ge::views_reset_visibility(g_app);
|
||||
ge::views_apply_visibility(g_app);
|
||||
|
||||
// Reaplica types.yaml + atlas. Sin esto, los tipos pierden
|
||||
// color/shape/icon tras reload (todo nodo vuelve a circulo
|
||||
// gris). Mismo flujo que reload_after_mutation.
|
||||
if (!g_app.parsed_types.entities.empty() ||
|
||||
!g_app.parsed_types.relations.empty()) {
|
||||
std::vector<uint16_t> cps =
|
||||
ge::apply_types_yaml(g_graph, g_app.parsed_types);
|
||||
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
|
||||
g_atlas = ge::build_icon_atlas(cps);
|
||||
}
|
||||
|
||||
// Restaura posiciones guardadas para nodos preexistentes.
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
(void)restored;
|
||||
|
||||
// (C) Halo placement: huerfanos creados por enrichers se
|
||||
// colocan junto a su primer vecino con posicion conocida,
|
||||
// evitando solapamiento con nodos existentes (issue 0031).
|
||||
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
|
||||
// Halo placement: junto a vecino con posicion conocida; si no
|
||||
// tiene vecino (caso tipico cuando el agente crea un nodo
|
||||
// aislado via MCP node_create), DENTRO de la camara visible
|
||||
// con anti-colision. Convencion: world_pos == cam_pos cuando
|
||||
// el nodo cae en el centro de la pantalla (graph_viewport.cpp
|
||||
// L23: gx = (vx - center) / zoom + cam_x).
|
||||
float cam_cx = g_viewport.cam_x;
|
||||
float cam_cy = g_viewport.cam_y;
|
||||
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
|
||||
? g_viewport.zoom : 0.01f);
|
||||
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
|
||||
/*use_camera=*/true,
|
||||
cam_cx, cam_cy, cam_r);
|
||||
g_graph.update_bounds();
|
||||
|
||||
// (B) NO graph_viewport_fit en reloads: preserva camara del
|
||||
// usuario (issue 0031).
|
||||
|
||||
// (E) Physics siempre pausadas tras reload (issue 0031).
|
||||
// Physics pausadas tras reload (issue 0031).
|
||||
g_viewport.layout_running = false;
|
||||
|
||||
// Refresca el indice user_data -> sql id (puede haber nuevos
|
||||
@@ -1051,25 +1305,17 @@ static void render() {
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
(void)restored;
|
||||
|
||||
// Centro del area visible en world coords (para que los nuevos nodos
|
||||
// aparezcan donde el usuario esta mirando, no en el origen).
|
||||
float cx = -g_viewport.cam_x;
|
||||
float cy = -g_viewport.cam_y;
|
||||
float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f);
|
||||
|
||||
// Reparte los nodos sin posicion en un anillo poisson alrededor del
|
||||
// centro visible. Determinista por user_data para que el mismo nodo
|
||||
// caiga siempre en el mismo sitio entre reloads.
|
||||
for (int i = 0; i < g_graph.node_count; ++i) {
|
||||
GraphNode& n = g_graph.nodes[i];
|
||||
if (n.x != 0.0f || n.y != 0.0f) continue;
|
||||
uint64_t h = n.user_data ? n.user_data : (uint64_t)i * 2654435761ull;
|
||||
float a = (float)((h >> 0) & 0xFFFF) / 65535.0f * 6.2831853f;
|
||||
float r = spread_r * (0.4f + (float)((h >> 16) & 0xFFFF) / 65535.0f * 0.6f);
|
||||
n.x = cx + std::cos(a) * r;
|
||||
n.y = cy + std::sin(a) * r;
|
||||
n.vx = n.vy = 0.0f;
|
||||
}
|
||||
// Halo placement: prefiere vecino, fallback a la camara con anti-
|
||||
// colision. Los nodos nuevos aparecen DENTRO de la camara y NO
|
||||
// encima de otros — el usuario los ve sin pan/zoom.
|
||||
// (cam_x, cam_y) es el world point en el centro de la pantalla.
|
||||
float cam_cx = g_viewport.cam_x;
|
||||
float cam_cy = g_viewport.cam_y;
|
||||
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
|
||||
? g_viewport.zoom : 0.01f);
|
||||
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
|
||||
/*use_camera=*/true,
|
||||
cam_cx, cam_cy, cam_r);
|
||||
g_graph.update_bounds();
|
||||
g_atlas_bound = false;
|
||||
g_gpu_dirty = true;
|
||||
@@ -1480,6 +1726,12 @@ static void render() {
|
||||
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
|
||||
ge::views_jobs(g_app);
|
||||
|
||||
// Chat panel (claude -p) — flotante, dockeable.
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.55f, top + 40.0f),
|
||||
ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
|
||||
ge::chat_render(&g_app.panel_chat);
|
||||
|
||||
g_first_render = false;
|
||||
}
|
||||
|
||||
@@ -1666,6 +1918,8 @@ int main(int argc, char** argv) {
|
||||
if (!g_input_path.empty()) {
|
||||
load_input();
|
||||
}
|
||||
panel_state_load_db(g_layout_db_path, g_panels,
|
||||
sizeof(g_panels) / sizeof(g_panels[0]));
|
||||
} else {
|
||||
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
|
||||
// crea default si no existe ninguno.
|
||||
@@ -1695,6 +1949,8 @@ int main(int argc, char** argv) {
|
||||
ge::layout_store_open(g_layout_db_path.c_str());
|
||||
ge::project_settings_touch(target.c_str());
|
||||
load_input();
|
||||
panel_state_load_db(g_layout_db_path, g_panels,
|
||||
sizeof(g_panels) / sizeof(g_panels[0]));
|
||||
}
|
||||
|
||||
fn_ui::about_window_set_info(
|
||||
@@ -1716,6 +1972,27 @@ int main(int argc, char** argv) {
|
||||
const char* app_db = g_layout_db_path.empty()
|
||||
? "graph_explorer.db" : g_layout_db_path.c_str();
|
||||
|
||||
// Layout storage — guardado/cargado de layouts ImGui en
|
||||
// graph_explorer.db. El menu Layouts del menubar consume estos cb.
|
||||
if (g_layout_db_path.empty()) {
|
||||
std::fprintf(stderr,
|
||||
"[graph_explorer] layout storage skipped (no db_path)\n");
|
||||
} else {
|
||||
g_layout_storage = fn_ui::layout_storage_open(
|
||||
g_layout_db_path.c_str());
|
||||
if (g_layout_storage) {
|
||||
fn_ui::layout_storage_make_callbacks(
|
||||
g_layout_storage, g_layout_cb);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] layout storage abierto en %s\n",
|
||||
g_layout_db_path.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"[graph_explorer] layout_storage_open fallo: %s\n",
|
||||
g_layout_db_path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
ge::enrichers_load(enrichers_dir.c_str());
|
||||
if (!ge::jobs_init(app_db,
|
||||
g_input.uri ? g_input.uri : "",
|
||||
@@ -1730,6 +2007,16 @@ int main(int argc, char** argv) {
|
||||
enrichers_dir.c_str(), registry_root.c_str(),
|
||||
(int)ge::enrichers_all().size());
|
||||
}
|
||||
|
||||
// Chat panel (claude -p) — el agente invoca gx-cli para mutar
|
||||
// operations.db. agent_mutations counter en graph_explorer.db dispara
|
||||
// reload del viewport en cada cambio.
|
||||
if (!ge::chat_init(g_input.uri ? g_input.uri : "",
|
||||
app_db, app_dir.c_str())) {
|
||||
std::fprintf(stderr,
|
||||
"[graph_explorer] chat_init: claude no detectado "
|
||||
"(panel Chat deshabilitado)\n");
|
||||
}
|
||||
}
|
||||
|
||||
int rc = fn::run_app(
|
||||
@@ -1739,11 +2026,29 @@ int main(int argc, char** argv) {
|
||||
.viewports = true,
|
||||
.panels = g_panels,
|
||||
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
|
||||
.layouts_cb = g_layout_storage ? &g_layout_cb : nullptr,
|
||||
.init_gl_loader = true},
|
||||
render);
|
||||
|
||||
// Auto-save de posiciones de nodos al salir — sin esto las posiciones se
|
||||
// pierden si el usuario nunca presiona "Save layout" (issue 0031 + nudge).
|
||||
if (g_loaded && g_graph_hash != 0) {
|
||||
int n = ge::layout_store_save(g_graph_hash, g_graph);
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] auto-saved %d node positions on exit\n", n);
|
||||
}
|
||||
|
||||
// Auto-save de paneles abiertos/cerrados al salir.
|
||||
panel_state_save_db(g_layout_db_path, g_panels,
|
||||
sizeof(g_panels) / sizeof(g_panels[0]));
|
||||
|
||||
// Cleanup
|
||||
ge::chat_shutdown();
|
||||
ge::jobs_shutdown();
|
||||
if (g_layout_storage) {
|
||||
fn_ui::layout_storage_close(g_layout_storage);
|
||||
g_layout_storage = nullptr;
|
||||
}
|
||||
if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
|
||||
if (g_atlas) graph_icons_destroy(g_atlas);
|
||||
graph_viewport_destroy(g_viewport);
|
||||
|
||||
Reference in New Issue
Block a user