@hienlh/ppm 0.9.53 → 0.9.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/web/assets/{chat-tab-DvNEQYEe.js → chat-tab-SfXtOm9d.js} +1 -1
- package/dist/web/assets/{code-editor-CoT017Ah.js → code-editor-DAZvtAlT.js} +1 -1
- package/dist/web/assets/database-viewer-C5fco1jm.js +1 -0
- package/dist/web/assets/{diff-viewer-D0tuen4I.js → diff-viewer-ShRSPvsf.js} +1 -1
- package/dist/web/assets/{extension-webview-Ba5aeo9r.js → extension-webview-CWJRMPfV.js} +1 -1
- package/dist/web/assets/{git-graph-BnJrVPxJ.js → git-graph-h0QmXMdZ.js} +1 -1
- package/dist/web/assets/{index-DUQgLj0D.js → index-CDlrGSwd.js} +4 -4
- package/dist/web/assets/{index-BEfMoc_W.css → index-DVuSY0BZ.css} +1 -1
- package/dist/web/assets/keybindings-store-wbHg-S_v.js +1 -0
- package/dist/web/assets/{markdown-renderer-BuGSrE3y.js → markdown-renderer-CSEmmMWt.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-DsbrWNUP.js → port-forwarding-tab-Cts6tMFn.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Bh6YmZPq.js → postgres-viewer-CiQC1sf9.js} +1 -1
- package/dist/web/assets/{settings-tab-BnzFtexC.js → settings-tab-CQx6aHtO.js} +1 -1
- package/dist/web/assets/sqlite-viewer-FQfCkjU6.js +1 -0
- package/dist/web/assets/{terminal-tab-fnZvscaH.js → terminal-tab-C2SnOqxn.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BdcKAZ69.js → use-monaco-theme-VPgvhMpB.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +52 -13
- package/docs/project-changelog.md +45 -1
- package/docs/project-roadmap.md +1 -1
- package/docs/system-architecture.md +121 -9
- package/package.json +1 -1
- package/src/cli/commands/bot-cmd.ts +144 -240
- package/src/server/routes/database.ts +31 -0
- package/src/server/routes/settings.ts +13 -0
- package/src/server/routes/sqlite.ts +14 -0
- package/src/services/database/postgres-adapter.ts +8 -0
- package/src/services/database/sqlite-adapter.ts +5 -0
- package/src/services/db.service.ts +109 -1
- package/src/services/postgres.service.ts +12 -0
- package/src/services/ppmbot/ppmbot-delegation.ts +112 -0
- package/src/services/ppmbot/ppmbot-service.ts +194 -369
- package/src/services/ppmbot/ppmbot-session.ts +85 -108
- package/src/services/ppmbot/ppmbot-telegram.ts +5 -16
- package/src/services/sqlite.service.ts +10 -0
- package/src/types/config.ts +1 -3
- package/src/types/database.ts +3 -0
- package/src/types/ppmbot.ts +21 -0
- package/src/web/components/database/database-viewer.tsx +50 -8
- package/src/web/components/database/use-database.ts +13 -1
- package/src/web/components/settings/ppmbot-settings-section.tsx +87 -26
- package/src/web/components/sqlite/sqlite-data-grid.tsx +55 -8
- package/src/web/components/sqlite/sqlite-viewer.tsx +1 -0
- package/src/web/components/sqlite/use-sqlite.ts +16 -1
- package/dist/web/assets/database-viewer-C3wK7cDk.js +0 -1
- package/dist/web/assets/keybindings-store-CkGFjxkX.js +0 -1
- package/dist/web/assets/sqlite-viewer-Cu3_hf07.js +0 -1
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/test-session-ops.mjs +0 -444
- package/test-tokens.mjs +0 -212
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { chatService } from "../chat.service.ts";
|
|
@@ -8,107 +8,102 @@ import {
|
|
|
8
8
|
createPPMBotSession,
|
|
9
9
|
deactivatePPMBotSession,
|
|
10
10
|
touchPPMBotSession,
|
|
11
|
-
getRecentPPMBotSessions,
|
|
12
11
|
getDistinctPPMBotProjectNames,
|
|
13
|
-
setSessionTitle,
|
|
14
12
|
} from "../db.service.ts";
|
|
15
13
|
import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
|
|
16
14
|
import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
|
|
17
15
|
|
|
18
|
-
export
|
|
19
|
-
/** In-memory cache: telegramChatId → active session */
|
|
20
|
-
private activeSessions = new Map<string, PPMBotActiveSession>();
|
|
16
|
+
export const DEFAULT_COORDINATOR_IDENTITY = `# PPMBot — AI Project Coordinator
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
* Get active session for chatId. If none exists, create one for the
|
|
24
|
-
* given project (or default project from config).
|
|
25
|
-
*/
|
|
26
|
-
async getOrCreateSession(
|
|
27
|
-
chatId: string,
|
|
28
|
-
projectName?: string,
|
|
29
|
-
): Promise<PPMBotActiveSession> {
|
|
30
|
-
const cached = this.activeSessions.get(chatId);
|
|
31
|
-
if (cached && (!projectName || cached.projectName === projectName)) {
|
|
32
|
-
touchPPMBotSession(cached.sessionId);
|
|
33
|
-
return cached;
|
|
34
|
-
}
|
|
18
|
+
You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram.
|
|
35
19
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
20
|
+
## Role
|
|
21
|
+
- Answer direct questions immediately (coding, general knowledge, quick advice)
|
|
22
|
+
- Delegate project-specific tasks to subagents using \`ppm bot delegate\`
|
|
23
|
+
- Track delegated task status and report results proactively
|
|
24
|
+
- Remember user preferences across conversations
|
|
25
|
+
- Act as a team leader coordinating work across multiple projects
|
|
43
26
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
27
|
+
## Decision Framework
|
|
28
|
+
1. Can I answer this directly without project context? → Answer now
|
|
29
|
+
2. Does this reference a specific project or need file access? → Delegate
|
|
30
|
+
3. Is this about PPM config or bot management? → Handle directly
|
|
31
|
+
4. Ambiguous project? → Ask user to clarify
|
|
48
32
|
|
|
49
|
-
|
|
50
|
-
}
|
|
33
|
+
## Coordination Tools (via Bash)
|
|
51
34
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
projectName: string,
|
|
56
|
-
): Promise<PPMBotActiveSession> {
|
|
57
|
-
await this.closeSession(chatId);
|
|
58
|
-
return this.getOrCreateSession(chatId, projectName);
|
|
59
|
-
}
|
|
35
|
+
### Delegate a task to a project
|
|
36
|
+
ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
|
|
37
|
+
Returns task ID. Tell user you're working on it.
|
|
60
38
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const active = this.activeSessions.get(chatId);
|
|
64
|
-
if (active) {
|
|
65
|
-
deactivatePPMBotSession(active.sessionId);
|
|
66
|
-
this.activeSessions.delete(chatId);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
39
|
+
### Check task status
|
|
40
|
+
ppm bot task-status <task-id>
|
|
69
41
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return this.activeSessions.get(chatId) ?? null;
|
|
73
|
-
}
|
|
42
|
+
### Get task result
|
|
43
|
+
ppm bot task-result <task-id>
|
|
74
44
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return getRecentPPMBotSessions(chatId, limit);
|
|
78
|
-
}
|
|
45
|
+
### List recent tasks
|
|
46
|
+
ppm bot tasks
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const sessions = getRecentPPMBotSessions(chatId, 20);
|
|
86
|
-
const target = sessions[sessionIndex - 1];
|
|
87
|
-
if (!target) return null;
|
|
48
|
+
## Response Style
|
|
49
|
+
- Keep responses concise (Telegram context — mobile-friendly)
|
|
50
|
+
- Use short paragraphs, no walls of text
|
|
51
|
+
- When delegating: acknowledge immediately, notify on completion
|
|
52
|
+
- Support Vietnamese and English naturally
|
|
88
53
|
|
|
89
|
-
|
|
54
|
+
## Important
|
|
55
|
+
- When delegating, write an enriched prompt with full context — not just the raw user message
|
|
56
|
+
- Include relevant details: what the user wants, which files/features, acceptance criteria
|
|
57
|
+
- Each delegation creates a fresh AI session in the target project workspace
|
|
58
|
+
`;
|
|
90
59
|
|
|
91
|
-
|
|
92
|
-
|
|
60
|
+
/** Ensure ~/.ppm/bot/ workspace exists with coordinator.md */
|
|
61
|
+
export function ensureCoordinatorWorkspace(): void {
|
|
62
|
+
const botDir = join(homedir(), ".ppm", "bot");
|
|
63
|
+
const coordinatorMd = join(botDir, "coordinator.md");
|
|
64
|
+
const settingsDir = join(botDir, ".claude");
|
|
65
|
+
const settingsFile = join(settingsDir, "settings.local.json");
|
|
93
66
|
|
|
94
|
-
|
|
67
|
+
mkdirSync(botDir, { recursive: true });
|
|
68
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
69
|
+
|
|
70
|
+
if (!existsSync(coordinatorMd)) {
|
|
71
|
+
writeFileSync(coordinatorMd, DEFAULT_COORDINATOR_IDENTITY);
|
|
72
|
+
}
|
|
73
|
+
if (!existsSync(settingsFile)) {
|
|
74
|
+
writeFileSync(settingsFile, JSON.stringify({
|
|
75
|
+
permissions: { allow: ["Bash", "Read", "Write", "Edit", "Glob", "Grep"] },
|
|
76
|
+
}, null, 2));
|
|
95
77
|
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class PPMBotSessionManager {
|
|
81
|
+
/** In-memory cache: telegramChatId → coordinator session */
|
|
82
|
+
private coordinatorSessions = new Map<string, PPMBotActiveSession>();
|
|
96
83
|
|
|
97
|
-
/**
|
|
98
|
-
async
|
|
99
|
-
chatId
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!target) return null;
|
|
84
|
+
/** Get or create coordinator session for chatId (always ~/.ppm/bot/) */
|
|
85
|
+
async getCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
|
|
86
|
+
const cached = this.coordinatorSessions.get(chatId);
|
|
87
|
+
if (cached) {
|
|
88
|
+
touchPPMBotSession(cached.sessionId);
|
|
89
|
+
return cached;
|
|
90
|
+
}
|
|
105
91
|
|
|
106
|
-
|
|
92
|
+
// Check DB for existing coordinator session
|
|
93
|
+
const dbSession = getActivePPMBotSession(chatId, "bot");
|
|
94
|
+
if (dbSession) {
|
|
95
|
+
return this.resumeFromDb(chatId, dbSession);
|
|
96
|
+
}
|
|
107
97
|
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
return this.createCoordinatorSession(chatId);
|
|
99
|
+
}
|
|
110
100
|
|
|
111
|
-
|
|
101
|
+
/** Rotate coordinator session (context window near limit) */
|
|
102
|
+
async rotateCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
|
|
103
|
+
const old = this.coordinatorSessions.get(chatId);
|
|
104
|
+
if (old) deactivatePPMBotSession(old.sessionId);
|
|
105
|
+
this.coordinatorSessions.delete(chatId);
|
|
106
|
+
return this.createCoordinatorSession(chatId);
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
/**
|
|
@@ -120,7 +115,6 @@ export class PPMBotSessionManager {
|
|
|
120
115
|
if (!projects?.length) return null;
|
|
121
116
|
|
|
122
117
|
const lower = input.toLowerCase();
|
|
123
|
-
|
|
124
118
|
const exact = projects.find((p) => p.name.toLowerCase() === lower);
|
|
125
119
|
if (exact) return { name: exact.name, path: exact.path };
|
|
126
120
|
|
|
@@ -130,13 +124,6 @@ export class PPMBotSessionManager {
|
|
|
130
124
|
return null;
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
/** Update session title (e.g. after first message) */
|
|
134
|
-
updateSessionTitle(sessionId: string, firstMessage: string): void {
|
|
135
|
-
const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
|
|
136
|
-
const title = `[PPM] ${preview}`;
|
|
137
|
-
setSessionTitle(sessionId, title);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
127
|
/** Get list of available project names (config + sessions history) */
|
|
141
128
|
getProjectNames(): string[] {
|
|
142
129
|
const configured = (configService.get("projects") as ProjectConfig[])?.map((p) => p.name) ?? [];
|
|
@@ -147,13 +134,7 @@ export class PPMBotSessionManager {
|
|
|
147
134
|
|
|
148
135
|
// ── Private ─────────────────────────────────────────────────────
|
|
149
136
|
|
|
150
|
-
private
|
|
151
|
-
const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
|
|
152
|
-
return cfg?.default_project || "";
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Fallback project when nothing is configured: ~/.ppm/bot/ */
|
|
156
|
-
private getFallbackProject(): { name: string; path: string } {
|
|
137
|
+
private getCoordinatorProject(): { name: string; path: string } {
|
|
157
138
|
const botDir = join(homedir(), ".ppm", "bot");
|
|
158
139
|
if (!existsSync(botDir)) mkdirSync(botDir, { recursive: true });
|
|
159
140
|
return { name: "bot", path: botDir };
|
|
@@ -164,16 +145,14 @@ export class PPMBotSessionManager {
|
|
|
164
145
|
return cfg?.default_provider || configService.get("ai").default_provider;
|
|
165
146
|
}
|
|
166
147
|
|
|
167
|
-
private async
|
|
168
|
-
|
|
169
|
-
project: { name: string; path: string },
|
|
170
|
-
): Promise<PPMBotActiveSession> {
|
|
148
|
+
private async createCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
|
|
149
|
+
const project = this.getCoordinatorProject();
|
|
171
150
|
const providerId = this.getDefaultProvider();
|
|
172
151
|
|
|
173
152
|
const session = await chatService.createSession(providerId, {
|
|
174
153
|
projectName: project.name,
|
|
175
154
|
projectPath: project.path,
|
|
176
|
-
title: `[PPM]
|
|
155
|
+
title: `[PPM] Coordinator`,
|
|
177
156
|
});
|
|
178
157
|
|
|
179
158
|
createPPMBotSession(chatId, session.id, providerId, project.name, project.path);
|
|
@@ -186,21 +165,19 @@ export class PPMBotSessionManager {
|
|
|
186
165
|
projectPath: project.path,
|
|
187
166
|
};
|
|
188
167
|
|
|
189
|
-
this.
|
|
168
|
+
this.coordinatorSessions.set(chatId, active);
|
|
190
169
|
return active;
|
|
191
170
|
}
|
|
192
171
|
|
|
193
|
-
private async resumeFromDb(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
project: { name: string; path: string },
|
|
197
|
-
): Promise<PPMBotActiveSession> {
|
|
172
|
+
private async resumeFromDb(chatId: string, dbSession: PPMBotSessionRow): Promise<PPMBotActiveSession> {
|
|
173
|
+
const project = this.getCoordinatorProject();
|
|
174
|
+
|
|
198
175
|
try {
|
|
199
176
|
await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
|
|
200
177
|
} catch {
|
|
201
178
|
console.warn(`[ppmbot] Failed to resume session ${dbSession.session_id}, creating new`);
|
|
202
179
|
deactivatePPMBotSession(dbSession.session_id);
|
|
203
|
-
return this.
|
|
180
|
+
return this.createCoordinatorSession(chatId);
|
|
204
181
|
}
|
|
205
182
|
|
|
206
183
|
touchPPMBotSession(dbSession.session_id);
|
|
@@ -213,7 +190,7 @@ export class PPMBotSessionManager {
|
|
|
213
190
|
projectPath: project.path,
|
|
214
191
|
};
|
|
215
192
|
|
|
216
|
-
this.
|
|
193
|
+
this.coordinatorSessions.set(chatId, active);
|
|
217
194
|
return active;
|
|
218
195
|
}
|
|
219
196
|
}
|
|
@@ -10,10 +10,9 @@ const POLL_TIMEOUT = 25;
|
|
|
10
10
|
const MIN_EDIT_INTERVAL = 1000;
|
|
11
11
|
const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
|
|
12
12
|
|
|
13
|
-
/** Known PPMBot slash commands */
|
|
13
|
+
/** Known PPMBot slash commands (coordinator: 3 public + 1 hidden) */
|
|
14
14
|
const COMMANDS = new Set([
|
|
15
|
-
"start", "
|
|
16
|
-
"status", "stop", "memory", "forget", "remember", "restart", "version", "help",
|
|
15
|
+
"start", "status", "help", "restart",
|
|
17
16
|
]);
|
|
18
17
|
|
|
19
18
|
export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
|
|
@@ -42,19 +41,9 @@ export class PPMBotTelegram {
|
|
|
42
41
|
try {
|
|
43
42
|
await this.callApi("setMyCommands", {
|
|
44
43
|
commands: [
|
|
45
|
-
{ command: "start", description: "
|
|
46
|
-
{ command: "
|
|
47
|
-
{ command: "
|
|
48
|
-
{ command: "sessions", description: "List recent sessions" },
|
|
49
|
-
{ command: "resume", description: "Resume a previous session" },
|
|
50
|
-
{ command: "status", description: "Current project/session info" },
|
|
51
|
-
{ command: "stop", description: "End current session" },
|
|
52
|
-
{ command: "memory", description: "Show project memories" },
|
|
53
|
-
{ command: "forget", description: "Remove matching memories" },
|
|
54
|
-
{ command: "remember", description: "Save a fact" },
|
|
55
|
-
{ command: "restart", description: "Restart PPM server" },
|
|
56
|
-
{ command: "version", description: "Show PPM version" },
|
|
57
|
-
{ command: "help", description: "Show all commands" },
|
|
44
|
+
{ command: "start", description: "Welcome + list projects" },
|
|
45
|
+
{ command: "status", description: "Running tasks + delegations" },
|
|
46
|
+
{ command: "help", description: "Show commands" },
|
|
58
47
|
],
|
|
59
48
|
});
|
|
60
49
|
console.log("[ppmbot] Commands registered");
|
|
@@ -137,6 +137,16 @@ class SqliteService {
|
|
|
137
137
|
db.run(`UPDATE "${table}" SET "${column}" = ? WHERE "${pkColumn}" = ?`, [value as never, rowid]);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** Delete a row by primary key */
|
|
141
|
+
deleteRow(
|
|
142
|
+
projectPath: string, dbPath: string, table: string,
|
|
143
|
+
pkValue: unknown, pkColumn = "rowid",
|
|
144
|
+
): void {
|
|
145
|
+
const abs = this.resolvePath(projectPath, dbPath);
|
|
146
|
+
const db = this.open(abs);
|
|
147
|
+
db.run(`DELETE FROM "${table}" WHERE "${pkColumn}" = ?`, [pkValue as never]);
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
/** Close all cached databases (for shutdown) */
|
|
141
151
|
closeAll() {
|
|
142
152
|
for (const absPath of this.cache.keys()) this.close(absPath);
|
package/src/types/config.ts
CHANGED
|
@@ -11,7 +11,6 @@ export interface TelegramConfig {
|
|
|
11
11
|
export interface PPMBotConfig {
|
|
12
12
|
enabled: boolean;
|
|
13
13
|
default_provider: string;
|
|
14
|
-
default_project: string;
|
|
15
14
|
system_prompt: string;
|
|
16
15
|
show_tool_calls: boolean;
|
|
17
16
|
show_thinking: boolean;
|
|
@@ -102,8 +101,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
102
101
|
clawbot: {
|
|
103
102
|
enabled: false,
|
|
104
103
|
default_provider: "claude",
|
|
105
|
-
|
|
106
|
-
system_prompt: "You are PPMBot, a helpful AI coding assistant on Telegram. Keep responses concise and mobile-friendly. Use short paragraphs. When showing code, use compact examples. Be direct and helpful.",
|
|
104
|
+
system_prompt: "",
|
|
107
105
|
show_tool_calls: true,
|
|
108
106
|
show_thinking: false,
|
|
109
107
|
permission_mode: "bypassPermissions",
|
package/src/types/database.ts
CHANGED
|
@@ -47,4 +47,7 @@ export interface DatabaseAdapter {
|
|
|
47
47
|
updateCell(config: DbConnectionConfig, table: string, opts: {
|
|
48
48
|
schema?: string; pkColumn: string; pkValue: unknown; column: string; value: unknown;
|
|
49
49
|
}): Promise<void>;
|
|
50
|
+
deleteRow(config: DbConnectionConfig, table: string, opts: {
|
|
51
|
+
schema?: string; pkColumn: string; pkValue: unknown;
|
|
52
|
+
}): Promise<void>;
|
|
50
53
|
}
|
package/src/types/ppmbot.ts
CHANGED
|
@@ -90,6 +90,27 @@ export interface MemoryRecallResult {
|
|
|
90
90
|
rank?: number;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Bot task row from SQLite */
|
|
94
|
+
export interface BotTask {
|
|
95
|
+
id: string;
|
|
96
|
+
chatId: string;
|
|
97
|
+
projectName: string;
|
|
98
|
+
projectPath: string;
|
|
99
|
+
prompt: string;
|
|
100
|
+
status: BotTaskStatus;
|
|
101
|
+
resultSummary: string | null;
|
|
102
|
+
resultFull: string | null;
|
|
103
|
+
sessionId: string | null;
|
|
104
|
+
error: string | null;
|
|
105
|
+
reported: boolean;
|
|
106
|
+
timeoutMs: number;
|
|
107
|
+
createdAt: number;
|
|
108
|
+
startedAt: number | null;
|
|
109
|
+
completedAt: number | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type BotTaskStatus = "pending" | "running" | "completed" | "failed" | "timeout";
|
|
113
|
+
|
|
93
114
|
/** Paired chat row from SQLite */
|
|
94
115
|
export interface PPMBotPairedChat {
|
|
95
116
|
id: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
|
-
import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
|
2
|
+
import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw, Trash2 } from "lucide-react";
|
|
3
3
|
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
|
|
4
4
|
import CodeMirror from "@uiw/react-codemirror";
|
|
5
5
|
import { sql, PostgreSQL, SQLite } from "@codemirror/lang-sql";
|
|
@@ -51,7 +51,7 @@ export function DatabaseViewer({ metadata }: Props) {
|
|
|
51
51
|
{/* Data grid */}
|
|
52
52
|
<div className={`flex-1 overflow-hidden ${queryPanelOpen ? "max-h-[60%]" : ""}`}>
|
|
53
53
|
<DataGrid tableData={db.tableData} schema={db.schema} loading={db.loading}
|
|
54
|
-
page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} />
|
|
54
|
+
page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} onRowDelete={db.deleteRow} />
|
|
55
55
|
</div>
|
|
56
56
|
|
|
57
57
|
{/* Query editor */}
|
|
@@ -67,14 +67,16 @@ export function DatabaseViewer({ metadata }: Props) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/* ---------- Data Grid ---------- */
|
|
70
|
-
function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: {
|
|
70
|
+
function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate, onRowDelete }: {
|
|
71
71
|
tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
|
|
72
72
|
schema: DbColumnInfo[]; loading: boolean; page: number;
|
|
73
73
|
onPageChange: (p: number) => void;
|
|
74
74
|
onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
|
|
75
|
+
onRowDelete?: (pkCol: string, pkVal: unknown) => void;
|
|
75
76
|
}) {
|
|
76
77
|
const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
|
|
77
78
|
const [editValue, setEditValue] = useState("");
|
|
79
|
+
const [confirmDeleteIdx, setConfirmDeleteIdx] = useState<number | null>(null);
|
|
78
80
|
|
|
79
81
|
const pkCol = useMemo(() => schema.find((c) => c.pk)?.name ?? null, [schema]);
|
|
80
82
|
|
|
@@ -96,8 +98,16 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
|
|
|
96
98
|
|
|
97
99
|
const cancelEdit = useCallback(() => setEditingCell(null), []);
|
|
98
100
|
|
|
99
|
-
const
|
|
100
|
-
(tableData
|
|
101
|
+
const handleDelete = useCallback((rowIdx: number) => {
|
|
102
|
+
if (!tableData || !pkCol || !onRowDelete) return;
|
|
103
|
+
const row = tableData.rows[rowIdx];
|
|
104
|
+
if (!row) return;
|
|
105
|
+
onRowDelete(pkCol, row[pkCol]);
|
|
106
|
+
setConfirmDeleteIdx(null);
|
|
107
|
+
}, [tableData, pkCol, onRowDelete]);
|
|
108
|
+
|
|
109
|
+
const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
|
|
110
|
+
const dataCols: ColumnDef<Record<string, unknown>>[] = (tableData?.columns ?? []).map((col) => ({
|
|
101
111
|
id: col,
|
|
102
112
|
accessorFn: (row) => row[col],
|
|
103
113
|
header: () => <span className={schema.find((c) => c.name === col)?.pk ? "font-bold" : ""}>{col}</span>,
|
|
@@ -118,8 +128,40 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
|
|
|
118
128
|
</span>
|
|
119
129
|
);
|
|
120
130
|
},
|
|
121
|
-
}))
|
|
122
|
-
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
if (onRowDelete && pkCol) {
|
|
134
|
+
dataCols.push({
|
|
135
|
+
id: "_actions",
|
|
136
|
+
header: () => null,
|
|
137
|
+
cell: ({ row }) => {
|
|
138
|
+
const rowIdx = row.index;
|
|
139
|
+
const isConfirming = confirmDeleteIdx === rowIdx;
|
|
140
|
+
if (isConfirming) {
|
|
141
|
+
return (
|
|
142
|
+
<span className="flex items-center gap-1 whitespace-nowrap">
|
|
143
|
+
<button type="button" onClick={() => handleDelete(rowIdx)}
|
|
144
|
+
className="text-destructive text-[10px] font-medium hover:underline">Confirm</button>
|
|
145
|
+
<button type="button" onClick={() => setConfirmDeleteIdx(null)}
|
|
146
|
+
className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return (
|
|
151
|
+
<button type="button" onClick={() => setConfirmDeleteIdx(rowIdx)}
|
|
152
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity"
|
|
153
|
+
title="Delete row">
|
|
154
|
+
<Trash2 className="size-3" />
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
size: 60,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return dataCols;
|
|
163
|
+
},
|
|
164
|
+
[tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol, onRowDelete, confirmDeleteIdx, handleDelete]);
|
|
123
165
|
|
|
124
166
|
const table = useReactTable({ data: tableData?.rows ?? [], columns, getCoreRowModel: getCoreRowModel() });
|
|
125
167
|
|
|
@@ -150,7 +192,7 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
|
|
|
150
192
|
</thead>
|
|
151
193
|
<tbody>
|
|
152
194
|
{table.getRowModel().rows.map((row) => (
|
|
153
|
-
<tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
|
|
195
|
+
<tr key={row.id} className="group hover:bg-muted/30 border-b border-border/50">
|
|
154
196
|
{row.getVisibleCells().map((cell) => (
|
|
155
197
|
<td key={cell.id} className="px-2 py-1 max-w-[300px]">
|
|
156
198
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
@@ -111,10 +111,22 @@ export function useDatabase(connectionId: number) {
|
|
|
111
111
|
}
|
|
112
112
|
}, [base, selectedTable, selectedSchema, fetchTableData]);
|
|
113
113
|
|
|
114
|
+
const deleteRow = useCallback(async (pkColumn: string, pkValue: unknown) => {
|
|
115
|
+
if (!selectedTable) return;
|
|
116
|
+
const t = selectedTable;
|
|
117
|
+
const s = selectedSchema;
|
|
118
|
+
try {
|
|
119
|
+
await api.del(`${base}/row`, { table: t, schema: s, pkColumn, pkValue });
|
|
120
|
+
fetchTableData(t, s);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
setError((e as Error).message);
|
|
123
|
+
}
|
|
124
|
+
}, [base, selectedTable, selectedSchema, fetchTableData]);
|
|
125
|
+
|
|
114
126
|
return {
|
|
115
127
|
selectedTable, selectTable, tableData, schema,
|
|
116
128
|
loading, error, page, setPage: changePage,
|
|
117
129
|
queryResult, queryError, queryLoading, executeQuery,
|
|
118
|
-
updateCell, refreshData: fetchTableData,
|
|
130
|
+
updateCell, deleteRow, refreshData: fetchTableData,
|
|
119
131
|
};
|
|
120
132
|
}
|