lazy-gravity 0.7.2 โ 0.8.0
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/dist/bot/index.js +32 -2
- package/dist/commands/registerSlashCommands.js +5 -0
- package/dist/database/artifactThreadRepository.js +70 -0
- package/dist/database/userPreferenceRepository.js +34 -0
- package/dist/events/interactionCreateHandler.js +159 -1
- package/dist/events/messageCreateHandler.js +2 -0
- package/dist/services/artifactService.js +282 -0
- package/dist/ui/artifactsUi.js +185 -0
- package/package.json +2 -1
package/dist/bot/index.js
CHANGED
|
@@ -58,6 +58,7 @@ const accountPreferenceRepository_1 = require("../database/accountPreferenceRepo
|
|
|
58
58
|
const workspaceBindingRepository_1 = require("../database/workspaceBindingRepository");
|
|
59
59
|
const channelPreferenceRepository_1 = require("../database/channelPreferenceRepository");
|
|
60
60
|
const chatSessionRepository_1 = require("../database/chatSessionRepository");
|
|
61
|
+
const artifactThreadRepository_1 = require("../database/artifactThreadRepository");
|
|
61
62
|
const workspaceService_1 = require("../services/workspaceService");
|
|
62
63
|
const workspaceCommandHandler_1 = require("../commands/workspaceCommandHandler");
|
|
63
64
|
const chatCommandHandler_1 = require("../commands/chatCommandHandler");
|
|
@@ -84,6 +85,8 @@ const autoAcceptUi_1 = require("../ui/autoAcceptUi");
|
|
|
84
85
|
const accountUi_1 = require("../ui/accountUi");
|
|
85
86
|
const outputUi_1 = require("../ui/outputUi");
|
|
86
87
|
const screenshotUi_1 = require("../ui/screenshotUi");
|
|
88
|
+
const artifactsUi_1 = require("../ui/artifactsUi");
|
|
89
|
+
const artifactService_1 = require("../services/artifactService");
|
|
87
90
|
const userPreferenceRepository_1 = require("../database/userPreferenceRepository");
|
|
88
91
|
const accountUtils_1 = require("../utils/accountUtils");
|
|
89
92
|
const plainTextFormatter_1 = require("../utils/plainTextFormatter");
|
|
@@ -706,6 +709,13 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
|
|
|
706
709
|
await options.channelManager.renameChannel(message.guild, message.channelId, formattedName);
|
|
707
710
|
options.chatSessionRepo.updateDisplayName(message.channelId, sessionInfo.title);
|
|
708
711
|
}
|
|
712
|
+
// Persist conversation_id for artifact picker resolution
|
|
713
|
+
if (options.artifactService) {
|
|
714
|
+
const convId = options.artifactService.findConversationByTitle(sessionInfo.title);
|
|
715
|
+
if (convId) {
|
|
716
|
+
options.chatSessionRepo.setConversationId(message.channelId, convId);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
709
719
|
}
|
|
710
720
|
}
|
|
711
721
|
catch (e) {
|
|
@@ -812,6 +822,8 @@ const startBot = async (cliLogLevel) => {
|
|
|
812
822
|
}
|
|
813
823
|
const workspaceBindingRepo = new workspaceBindingRepository_1.WorkspaceBindingRepository(db);
|
|
814
824
|
const chatSessionRepo = new chatSessionRepository_1.ChatSessionRepository(db);
|
|
825
|
+
const artifactThreadRepo = new artifactThreadRepository_1.ArtifactThreadRepository(db);
|
|
826
|
+
const artifactService = new artifactService_1.ArtifactService();
|
|
815
827
|
const workspaceService = new workspaceService_1.WorkspaceService(config.workspaceBaseDir);
|
|
816
828
|
const channelManager = new channelManager_1.ChannelManager();
|
|
817
829
|
// Auto-launch Antigravity with CDP port if not already running
|
|
@@ -967,8 +979,10 @@ const startBot = async (cliLogLevel) => {
|
|
|
967
979
|
channelPrefRepo,
|
|
968
980
|
chatSessionRepo,
|
|
969
981
|
chatSessionService,
|
|
982
|
+
artifactThreadRepo,
|
|
983
|
+
artifactService,
|
|
970
984
|
antigravityAccounts: config.antigravityAccounts,
|
|
971
|
-
handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepo),
|
|
985
|
+
handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepo, artifactService),
|
|
972
986
|
handleTemplateUse: async (interaction, templateId) => {
|
|
973
987
|
const template = templateRepo.findById(templateId);
|
|
974
988
|
if (!template) {
|
|
@@ -1055,6 +1069,7 @@ const startBot = async (cliLogLevel) => {
|
|
|
1055
1069
|
channelManager,
|
|
1056
1070
|
titleGenerator,
|
|
1057
1071
|
userPrefRepo,
|
|
1072
|
+
artifactService,
|
|
1058
1073
|
extractionMode: config.extractionMode,
|
|
1059
1074
|
},
|
|
1060
1075
|
});
|
|
@@ -1073,6 +1088,7 @@ const startBot = async (cliLogLevel) => {
|
|
|
1073
1088
|
chatSessionRepo,
|
|
1074
1089
|
channelManager,
|
|
1075
1090
|
titleGenerator,
|
|
1091
|
+
artifactService,
|
|
1076
1092
|
client,
|
|
1077
1093
|
sendPromptToAntigravity: async (_bridge, message, prompt, cdp, _modeService, _modelService, inboundImages = [], options) => promptDispatcher.send({
|
|
1078
1094
|
message,
|
|
@@ -1261,6 +1277,7 @@ const startBot = async (cliLogLevel) => {
|
|
|
1261
1277
|
{ command: 'stop', description: 'Interrupt active LLM generation' },
|
|
1262
1278
|
{ command: 'help', description: 'Show available commands' },
|
|
1263
1279
|
{ command: 'ping', description: 'Check bot latency' },
|
|
1280
|
+
{ command: 'artifacts', description: 'Browse session artifacts' },
|
|
1264
1281
|
]).catch((e) => {
|
|
1265
1282
|
logger_1.logger.warn('Failed to register Telegram commands:', e instanceof Error ? e.message : e);
|
|
1266
1283
|
});
|
|
@@ -1352,7 +1369,7 @@ async function autoRenameChannel(message, chatSessionRepo, titleGenerator, chann
|
|
|
1352
1369
|
/**
|
|
1353
1370
|
* Handle Discord Interactions API slash commands
|
|
1354
1371
|
*/
|
|
1355
|
-
async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo) {
|
|
1372
|
+
async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService) {
|
|
1356
1373
|
const commandName = interaction.commandName;
|
|
1357
1374
|
const getAccountPort = (accountName) => {
|
|
1358
1375
|
const match = antigravityAccounts.find((account) => account.name === accountName);
|
|
@@ -1476,6 +1493,11 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
|
|
|
1476
1493
|
'`/help` โ Show this help',
|
|
1477
1494
|
].join('\n')
|
|
1478
1495
|
},
|
|
1496
|
+
{
|
|
1497
|
+
name: '๐ Artifacts', value: [
|
|
1498
|
+
'`/artifacts` โ Browse and render generated artifacts from the active session',
|
|
1499
|
+
].join('\n')
|
|
1500
|
+
},
|
|
1479
1501
|
];
|
|
1480
1502
|
const helpOutputFormat = userPrefRepo?.getOutputFormat(interaction.user.id) ?? 'embed';
|
|
1481
1503
|
if (helpOutputFormat === 'plain') {
|
|
@@ -1828,6 +1850,14 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
|
|
|
1828
1850
|
await interaction.editReply({ content: codeBlock });
|
|
1829
1851
|
break;
|
|
1830
1852
|
}
|
|
1853
|
+
case 'artifacts': {
|
|
1854
|
+
await (0, artifactsUi_1.sendArtifactPickerUI)(interaction, {
|
|
1855
|
+
userPrefRepo,
|
|
1856
|
+
chatSessionRepo,
|
|
1857
|
+
artifactService
|
|
1858
|
+
});
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1831
1861
|
default:
|
|
1832
1862
|
await interaction.editReply({
|
|
1833
1863
|
content: `Unknown command: /${commandName}`,
|
|
@@ -148,6 +148,10 @@ const logsCommand = new discord_js_1.SlashCommandBuilder()
|
|
|
148
148
|
const pingCommand = new discord_js_1.SlashCommandBuilder()
|
|
149
149
|
.setName('ping')
|
|
150
150
|
.setDescription((0, i18n_1.t)('Check bot latency'));
|
|
151
|
+
/** /artifacts command definition */
|
|
152
|
+
const artifactsCommand = new discord_js_1.SlashCommandBuilder()
|
|
153
|
+
.setName('artifacts')
|
|
154
|
+
.setDescription((0, i18n_1.t)('Browse and view generated artifacts from the active session'));
|
|
151
155
|
/** Array of commands to register */
|
|
152
156
|
exports.slashCommands = [
|
|
153
157
|
helpCommand,
|
|
@@ -168,6 +172,7 @@ exports.slashCommands = [
|
|
|
168
172
|
outputCommand,
|
|
169
173
|
pingCommand,
|
|
170
174
|
logsCommand,
|
|
175
|
+
artifactsCommand,
|
|
171
176
|
];
|
|
172
177
|
/**
|
|
173
178
|
* Register slash commands with Discord
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ArtifactThreadRepository = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Repository for mapping (channel_id, conversation_id, filename) to Discord thread ID.
|
|
6
|
+
* This allows reusing the same thread for multiple renders of the same file.
|
|
7
|
+
*/
|
|
8
|
+
class ArtifactThreadRepository {
|
|
9
|
+
db;
|
|
10
|
+
constructor(db) {
|
|
11
|
+
this.db = db;
|
|
12
|
+
this.initialize();
|
|
13
|
+
}
|
|
14
|
+
initialize() {
|
|
15
|
+
const tableInfo = this.db.prepare("PRAGMA table_info('artifact_threads')").all();
|
|
16
|
+
if (tableInfo.length === 0) {
|
|
17
|
+
this.db.exec(`
|
|
18
|
+
CREATE TABLE artifact_threads (
|
|
19
|
+
channel_id TEXT NOT NULL,
|
|
20
|
+
conversation_id TEXT NOT NULL,
|
|
21
|
+
filename TEXT NOT NULL,
|
|
22
|
+
thread_id TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (channel_id, conversation_id, filename)
|
|
24
|
+
)
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const hasConvId = tableInfo.some(col => col.name === 'conversation_id');
|
|
29
|
+
if (!hasConvId) {
|
|
30
|
+
// To safely migrate in sqlite, we recreate the table, since we need to change the primary key
|
|
31
|
+
this.db.exec(`
|
|
32
|
+
CREATE TABLE artifact_threads_new (
|
|
33
|
+
channel_id TEXT NOT NULL,
|
|
34
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
35
|
+
filename TEXT NOT NULL,
|
|
36
|
+
thread_id TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (channel_id, conversation_id, filename)
|
|
38
|
+
);
|
|
39
|
+
INSERT INTO artifact_threads_new (channel_id, conversation_id, filename, thread_id)
|
|
40
|
+
SELECT channel_id, '', filename, thread_id FROM artifact_threads;
|
|
41
|
+
DROP TABLE artifact_threads;
|
|
42
|
+
ALTER TABLE artifact_threads_new RENAME TO artifact_threads;
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get the stored thread ID for a specific file in a channel and conversation.
|
|
49
|
+
*/
|
|
50
|
+
getThreadId(channelId, conversationId, filename) {
|
|
51
|
+
const row = this.db.prepare('SELECT thread_id FROM artifact_threads WHERE channel_id = ? AND conversation_id = ? AND filename = ?').get(channelId, conversationId, filename);
|
|
52
|
+
return row?.thread_id ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Set the thread ID for a specific file in a channel and conversation.
|
|
56
|
+
*/
|
|
57
|
+
setThreadId(channelId, conversationId, filename, threadId) {
|
|
58
|
+
this.db.prepare(`
|
|
59
|
+
INSERT OR REPLACE INTO artifact_threads (channel_id, conversation_id, filename, thread_id)
|
|
60
|
+
VALUES (?, ?, ?, ?)
|
|
61
|
+
`).run(channelId, conversationId, filename, threadId);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Remove a stored thread ID.
|
|
65
|
+
*/
|
|
66
|
+
deleteThreadId(channelId, conversationId, filename) {
|
|
67
|
+
this.db.prepare('DELETE FROM artifact_threads WHERE channel_id = ? AND conversation_id = ? AND filename = ?').run(channelId, conversationId, filename);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.ArtifactThreadRepository = ArtifactThreadRepository;
|
|
@@ -25,6 +25,7 @@ class UserPreferenceRepository {
|
|
|
25
25
|
)
|
|
26
26
|
`);
|
|
27
27
|
this.migrateDefaultModel();
|
|
28
|
+
this.migrateArtifactRenderMode();
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
31
|
* Safe migration: add default_model column if it does not exist.
|
|
@@ -48,6 +49,16 @@ class UserPreferenceRepository {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Migration: add artifact_render_mode column.
|
|
54
|
+
* Default is 'thread'.
|
|
55
|
+
*/
|
|
56
|
+
migrateArtifactRenderMode() {
|
|
57
|
+
const columns = this.db.pragma('table_info(user_preferences)');
|
|
58
|
+
if (!columns.some(c => c.name === 'artifact_render_mode')) {
|
|
59
|
+
this.db.exec("ALTER TABLE user_preferences ADD COLUMN artifact_render_mode TEXT DEFAULT 'thread'");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
51
62
|
/**
|
|
52
63
|
* Get the output format preference for a user.
|
|
53
64
|
* Returns 'embed' as default if no preference is stored.
|
|
@@ -78,6 +89,28 @@ class UserPreferenceRepository {
|
|
|
78
89
|
const row = this.db.prepare('SELECT default_model FROM user_preferences WHERE user_id = ?').get(userId);
|
|
79
90
|
return row?.default_model ?? null;
|
|
80
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Get the artifact render mode preference for a user.
|
|
94
|
+
* Returns 'thread' as default.
|
|
95
|
+
*/
|
|
96
|
+
getArtifactRenderMode(userId) {
|
|
97
|
+
const row = this.db.prepare('SELECT artifact_render_mode FROM user_preferences WHERE user_id = ?').get(userId);
|
|
98
|
+
if (!row || row.artifact_render_mode === 'thread')
|
|
99
|
+
return 'thread';
|
|
100
|
+
return 'inline';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Set the artifact render mode preference (upsert).
|
|
104
|
+
*/
|
|
105
|
+
setArtifactRenderMode(userId, mode) {
|
|
106
|
+
this.db.prepare(`
|
|
107
|
+
INSERT INTO user_preferences (user_id, artifact_render_mode)
|
|
108
|
+
VALUES (?, ?)
|
|
109
|
+
ON CONFLICT(user_id)
|
|
110
|
+
DO UPDATE SET artifact_render_mode = excluded.artifact_render_mode,
|
|
111
|
+
updated_at = datetime('now')
|
|
112
|
+
`).run(userId, mode);
|
|
113
|
+
}
|
|
81
114
|
/**
|
|
82
115
|
* Set the default model for a user (upsert).
|
|
83
116
|
* Pass null to clear the default.
|
|
@@ -111,6 +144,7 @@ class UserPreferenceRepository {
|
|
|
111
144
|
defaultModel: row.default_model ?? null,
|
|
112
145
|
createdAt: row.created_at,
|
|
113
146
|
updatedAt: row.updated_at,
|
|
147
|
+
artifactRenderMode: row.artifact_render_mode ?? 'thread',
|
|
114
148
|
};
|
|
115
149
|
}
|
|
116
150
|
}
|
|
@@ -12,10 +12,20 @@ const cleanupCommandHandler_1 = require("../commands/cleanupCommandHandler");
|
|
|
12
12
|
const projectListUi_1 = require("../ui/projectListUi");
|
|
13
13
|
const modeService_1 = require("../services/modeService");
|
|
14
14
|
const sessionPickerUi_1 = require("../ui/sessionPickerUi");
|
|
15
|
+
const artifactsUi_1 = require("../ui/artifactsUi");
|
|
16
|
+
const artifactService_1 = require("../services/artifactService");
|
|
15
17
|
const accountUtils_1 = require("../utils/accountUtils");
|
|
16
18
|
const accountUi_1 = require("../ui/accountUi");
|
|
17
19
|
function createInteractionCreateHandler(deps) {
|
|
18
|
-
const getParentChannelId = (interaction) =>
|
|
20
|
+
const getParentChannelId = (interaction) => {
|
|
21
|
+
const channelId = interaction.channelId;
|
|
22
|
+
if (!channelId)
|
|
23
|
+
return null;
|
|
24
|
+
const parentId = 'channel' in interaction && interaction.channel && 'parentId' in interaction.channel
|
|
25
|
+
? interaction.channel.parentId
|
|
26
|
+
: null;
|
|
27
|
+
return (0, accountUtils_1.inferParentScopeChannelId)(channelId, parentId);
|
|
28
|
+
};
|
|
19
29
|
const getSessionAccountName = (channelId) => deps.chatSessionRepo?.findByChannelId(channelId)?.activeAccountName ?? null;
|
|
20
30
|
const resolveSelectedAccount = (channelId, userId, parentChannelId) => (0, accountUtils_1.resolveScopedAccountName)({
|
|
21
31
|
channelId,
|
|
@@ -664,6 +674,20 @@ function createInteractionCreateHandler(deps) {
|
|
|
664
674
|
}
|
|
665
675
|
return;
|
|
666
676
|
}
|
|
677
|
+
if (interaction.customId.startsWith(`${artifactsUi_1.ARTIFACT_THREAD_BTN}:`) || interaction.customId.startsWith(`${artifactsUi_1.ARTIFACT_INLINE_BTN}:`)) {
|
|
678
|
+
const newMode = interaction.customId.startsWith(`${artifactsUi_1.ARTIFACT_THREAD_BTN}:`) ? 'thread' : 'inline';
|
|
679
|
+
const passedConvId = interaction.customId.split(':')[1] || null;
|
|
680
|
+
if (deps.userPrefRepo) {
|
|
681
|
+
deps.userPrefRepo.setArtifactRenderMode(interaction.user.id, newMode);
|
|
682
|
+
}
|
|
683
|
+
await interaction.deferUpdate().catch(logger_1.logger.error);
|
|
684
|
+
await (0, artifactsUi_1.sendArtifactPickerUI)(interaction, {
|
|
685
|
+
userPrefRepo: deps.userPrefRepo,
|
|
686
|
+
chatSessionRepo: deps.chatSessionRepo,
|
|
687
|
+
artifactService: deps.artifactService
|
|
688
|
+
}, true, passedConvId);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
667
691
|
}
|
|
668
692
|
catch (error) {
|
|
669
693
|
logger_1.logger.error('Error during button interaction handling:', error);
|
|
@@ -817,6 +841,140 @@ function createInteractionCreateHandler(deps) {
|
|
|
817
841
|
}
|
|
818
842
|
return;
|
|
819
843
|
}
|
|
844
|
+
if (interaction.isStringSelectMenu() && interaction.customId === artifactsUi_1.ARTIFACT_SELECT_ID) {
|
|
845
|
+
if (!deps.config.allowedUserIds.includes(interaction.user.id)) {
|
|
846
|
+
await interaction.reply({ content: (0, i18n_1.t)('You do not have permission.'), flags: discord_js_1.MessageFlags.Ephemeral }).catch(logger_1.logger.error);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
await interaction.deferUpdate();
|
|
851
|
+
}
|
|
852
|
+
catch (deferError) {
|
|
853
|
+
if (deferError?.code === 10062 || deferError?.code === 40060) {
|
|
854
|
+
logger_1.logger.warn('[ArtifactSelect] deferUpdate expired. Skipping.');
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
logger_1.logger.error('[ArtifactSelect] deferUpdate failed:', deferError);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const selectedValue = interaction.values[0];
|
|
861
|
+
if (!selectedValue) {
|
|
862
|
+
await interaction.editReply({ content: 'No artifact selected.', components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const artifactService = new artifactService_1.ArtifactService();
|
|
867
|
+
// Resolve the selected artifact by rescanning and matching the encoded value
|
|
868
|
+
const channelId = interaction.channelId;
|
|
869
|
+
const sessionTitle = deps.chatSessionRepo?.findByChannelId(channelId)?.displayName?.trim() ?? '';
|
|
870
|
+
let conversationId = sessionTitle ? artifactService.findConversationByTitle(sessionTitle) : null;
|
|
871
|
+
if (!conversationId)
|
|
872
|
+
conversationId = artifactService.getLatestConversationWithArtifacts();
|
|
873
|
+
if (!conversationId) {
|
|
874
|
+
await interaction.editReply({ content: '๐ No artifacts found.', components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const artifacts = artifactService.listArtifacts(conversationId);
|
|
878
|
+
const decoded = artifactService.decodeSelectValue(selectedValue, artifacts);
|
|
879
|
+
if (!decoded) {
|
|
880
|
+
logger_1.logger.warn(`[ArtifactSelect] Could not decode value: ${selectedValue}`);
|
|
881
|
+
await interaction.editReply({ content: 'โ ๏ธ Could not identify the selected artifact.', components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const content = artifactService.getArtifactContent(decoded.conversationId, decoded.filename);
|
|
885
|
+
if (!content) {
|
|
886
|
+
await interaction.editReply({ content: 'โ ๏ธ Could not read artifact content.', components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
// Get user render mode
|
|
890
|
+
const renderMode = deps.userPrefRepo?.getArtifactRenderMode(interaction.user.id) ?? 'thread';
|
|
891
|
+
let targetChannel = interaction.channel;
|
|
892
|
+
if (renderMode === 'thread' && deps.artifactThreadRepo && interaction.channel?.threads) {
|
|
893
|
+
try {
|
|
894
|
+
const existingThreadId = deps.artifactThreadRepo.getThreadId(channelId, conversationId, decoded.filename);
|
|
895
|
+
let thread = null;
|
|
896
|
+
if (existingThreadId) {
|
|
897
|
+
try {
|
|
898
|
+
thread = await interaction.client.channels.fetch(existingThreadId);
|
|
899
|
+
if (thread && thread.archived) {
|
|
900
|
+
await thread.setArchived(false);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (fetchError) {
|
|
904
|
+
logger_1.logger.warn(`[ArtifactSelect] Could not fetch existing thread ${existingThreadId}:`, fetchError);
|
|
905
|
+
deps.artifactThreadRepo.deleteThreadId(channelId, conversationId, decoded.filename);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!thread) {
|
|
909
|
+
// Create new thread from the prompt message
|
|
910
|
+
const message = await interaction.editReply({
|
|
911
|
+
content: `๐ **Artifact: ${decoded.filename}**\n*Creating thread for persistent viewing...*`,
|
|
912
|
+
components: [],
|
|
913
|
+
allowedMentions: { parse: [] }
|
|
914
|
+
});
|
|
915
|
+
thread = await message.startThread({
|
|
916
|
+
name: `artifact: ${decoded.filename}`,
|
|
917
|
+
autoArchiveDuration: 10080,
|
|
918
|
+
});
|
|
919
|
+
deps.artifactThreadRepo.setThreadId(channelId, conversationId, decoded.filename, thread.id);
|
|
920
|
+
// Update the main message to link to the thread
|
|
921
|
+
await interaction.editReply({
|
|
922
|
+
content: `๐ **Artifact: ${decoded.filename}**\n*Thread created: <#${thread.id}>*`,
|
|
923
|
+
components: [],
|
|
924
|
+
allowedMentions: { parse: [] }
|
|
925
|
+
}).catch(logger_1.logger.error);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
// Reuse existing thread
|
|
929
|
+
await interaction.editReply({
|
|
930
|
+
content: `๐ **Artifact: ${decoded.filename}**\n*Updated in existing thread: <#${thread.id}>*`,
|
|
931
|
+
components: [],
|
|
932
|
+
allowedMentions: { parse: [] }
|
|
933
|
+
}).catch(logger_1.logger.error);
|
|
934
|
+
// Send a visual spacer to separate content
|
|
935
|
+
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
936
|
+
await thread.send({ content: `โโโโโโโโโโโโโโโโโโโโโโโโโ\n๐ **Artifact Re-rendered at ${now}**`, allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
937
|
+
}
|
|
938
|
+
targetChannel = thread;
|
|
939
|
+
}
|
|
940
|
+
catch (threadError) {
|
|
941
|
+
logger_1.logger.error('[ArtifactSelect] Thread creation/resume failed, falling back to inline:', threadError);
|
|
942
|
+
await interaction.editReply({ content: `๐ **${decoded.filename}**`, components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
// Inline rendering
|
|
947
|
+
await interaction.editReply({ content: `๐ **${decoded.filename}**`, components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
948
|
+
}
|
|
949
|
+
// Split into 2000-char chunks and send as follow-ups or thread messages
|
|
950
|
+
const MAX_CHUNK = 1990;
|
|
951
|
+
let remaining = content;
|
|
952
|
+
while (remaining.length > 0) {
|
|
953
|
+
let chunk;
|
|
954
|
+
if (remaining.length <= MAX_CHUNK) {
|
|
955
|
+
chunk = remaining;
|
|
956
|
+
remaining = '';
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
const splitAt = remaining.lastIndexOf('\n', MAX_CHUNK);
|
|
960
|
+
chunk = splitAt > 0 ? remaining.slice(0, splitAt) : remaining.slice(0, MAX_CHUNK);
|
|
961
|
+
remaining = remaining.slice(chunk.length).replace(/^\n/, '');
|
|
962
|
+
}
|
|
963
|
+
if (targetChannel && targetChannel.send) {
|
|
964
|
+
await targetChannel.send({ content: chunk, allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
await interaction.followUp({ content: chunk, allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
logger_1.logger.info(`[ArtifactSelect] Served artifact ${decoded.conversationId}/${decoded.filename} to channel=${channelId} (mode=${renderMode})`);
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
logger_1.logger.error('[ArtifactSelect] Error serving artifact:', error);
|
|
974
|
+
await interaction.editReply({ content: 'โ Error reading artifact.', components: [], allowedMentions: { parse: [] } }).catch(logger_1.logger.error);
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
820
978
|
if (interaction.isStringSelectMenu() && (0, projectListUi_1.isProjectSelectId)(interaction.customId)) {
|
|
821
979
|
if (!deps.config.allowedUserIds.includes(interaction.user.id)) {
|
|
822
980
|
await interaction.reply({ content: (0, i18n_1.t)('You do not have permission.'), flags: discord_js_1.MessageFlags.Ephemeral }).catch(logger_1.logger.error);
|
|
@@ -179,6 +179,7 @@ function createMessageCreateHandler(deps) {
|
|
|
179
179
|
channelManager: deps.channelManager,
|
|
180
180
|
titleGenerator: deps.titleGenerator,
|
|
181
181
|
userPrefRepo: deps.userPrefRepo,
|
|
182
|
+
artifactService: deps.artifactService,
|
|
182
183
|
extractionMode: deps.config.extractionMode,
|
|
183
184
|
responseTimeoutMs: deps.config.responseTimeoutMs,
|
|
184
185
|
});
|
|
@@ -358,6 +359,7 @@ function createMessageCreateHandler(deps) {
|
|
|
358
359
|
channelManager: deps.channelManager,
|
|
359
360
|
titleGenerator: deps.titleGenerator,
|
|
360
361
|
userPrefRepo: deps.userPrefRepo,
|
|
362
|
+
artifactService: deps.artifactService,
|
|
361
363
|
extractionMode: deps.config.extractionMode,
|
|
362
364
|
responseTimeoutMs: deps.config.responseTimeoutMs,
|
|
363
365
|
onFullCompletion: settle,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Artifact Service โ reads Antigravity artifacts from the local filesystem.
|
|
4
|
+
*
|
|
5
|
+
* Antigravity persists artifacts (implementation plans, tasks, walkthroughs)
|
|
6
|
+
* as Markdown files with companion `.metadata.json` files in:
|
|
7
|
+
* %USERPROFILE%/.gemini/antigravity/brain/<conversation-id>/
|
|
8
|
+
*
|
|
9
|
+
* This service locates relevant conversations and surfaces their artifacts
|
|
10
|
+
* for the /artifacts Discord command.
|
|
11
|
+
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.ArtifactService = void 0;
|
|
47
|
+
exports.artifactTypeLabel = artifactTypeLabel;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const os = __importStar(require("os"));
|
|
51
|
+
const logger_1 = require("../utils/logger");
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
const ARTIFACT_TYPE_LABELS = {
|
|
56
|
+
ARTIFACT_TYPE_IMPLEMENTATION_PLAN: '๐ Plan',
|
|
57
|
+
ARTIFACT_TYPE_TASK: 'โ
Task',
|
|
58
|
+
ARTIFACT_TYPE_WALKTHROUGH: '๐ถ Walkthrough',
|
|
59
|
+
ARTIFACT_TYPE_OTHER: '๐ Other',
|
|
60
|
+
};
|
|
61
|
+
function artifactTypeLabel(type) {
|
|
62
|
+
return ARTIFACT_TYPE_LABELS[type] ?? '๐ Artifact';
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// ArtifactService
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
class ArtifactService {
|
|
68
|
+
brainBasePath;
|
|
69
|
+
constructor(brainBasePath) {
|
|
70
|
+
this.brainBasePath =
|
|
71
|
+
brainBasePath ??
|
|
72
|
+
path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* List all conversations in the brain directory (UUIDs).
|
|
76
|
+
*/
|
|
77
|
+
listConversationIds() {
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.existsSync(this.brainBasePath))
|
|
80
|
+
return [];
|
|
81
|
+
return fs.readdirSync(this.brainBasePath).filter((entry) => {
|
|
82
|
+
const full = path.join(this.brainBasePath, entry);
|
|
83
|
+
return (fs.statSync(full).isDirectory() &&
|
|
84
|
+
/^[0-9a-f-]{36}$/.test(entry));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
logger_1.logger.warn(`[ArtifactService] Failed to list brain directory: ${err}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the .metadata.json file for a given artifact in a conversation.
|
|
94
|
+
*/
|
|
95
|
+
readMetadata(conversationId, mdFilename) {
|
|
96
|
+
const metaPath = path.join(this.brainBasePath, conversationId, `${mdFilename}.metadata.json`);
|
|
97
|
+
try {
|
|
98
|
+
if (!fs.existsSync(metaPath))
|
|
99
|
+
return null;
|
|
100
|
+
const raw = fs.readFileSync(metaPath, 'utf-8');
|
|
101
|
+
return JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* List all artifacts in a given conversation directory.
|
|
109
|
+
* An artifact must be a .md file with a companion .metadata.json.
|
|
110
|
+
*/
|
|
111
|
+
listArtifacts(conversationId) {
|
|
112
|
+
const convDir = path.join(this.brainBasePath, conversationId);
|
|
113
|
+
try {
|
|
114
|
+
if (!fs.existsSync(convDir))
|
|
115
|
+
return [];
|
|
116
|
+
const entries = fs.readdirSync(convDir);
|
|
117
|
+
const artifacts = [];
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
// Only look at .md files (not .metadata.json, .resolved, etc.)
|
|
120
|
+
if (!entry.endsWith('.md'))
|
|
121
|
+
continue;
|
|
122
|
+
const meta = this.readMetadata(conversationId, entry);
|
|
123
|
+
if (!meta)
|
|
124
|
+
continue; // Not a tracked artifact โ skip
|
|
125
|
+
artifacts.push({
|
|
126
|
+
conversationId,
|
|
127
|
+
filename: entry,
|
|
128
|
+
artifactType: meta.artifactType ?? 'ARTIFACT_TYPE_OTHER',
|
|
129
|
+
summary: meta.summary,
|
|
130
|
+
updatedAt: meta.updatedAt,
|
|
131
|
+
version: meta.version,
|
|
132
|
+
absolutePath: path.join(convDir, entry),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Sort by updatedAt descending (most recent first)
|
|
136
|
+
artifacts.sort((a, b) => {
|
|
137
|
+
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
138
|
+
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
139
|
+
return tb - ta;
|
|
140
|
+
});
|
|
141
|
+
return artifacts;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
logger_1.logger.warn(`[ArtifactService] Failed to list artifacts for ${conversationId}: ${err}`);
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Try to find a conversation UUID whose overview.txt contains the given session title.
|
|
150
|
+
* Returns the UUID or null if not found.
|
|
151
|
+
*/
|
|
152
|
+
findConversationByTitle(title) {
|
|
153
|
+
if (!title || !title.trim())
|
|
154
|
+
return null;
|
|
155
|
+
const needle = title.trim().toLowerCase();
|
|
156
|
+
const ids = this.listConversationIds();
|
|
157
|
+
// Sort by directory mtime descending (most recent first)
|
|
158
|
+
const sortedIds = ids
|
|
159
|
+
.map((id) => {
|
|
160
|
+
const full = path.join(this.brainBasePath, id);
|
|
161
|
+
try {
|
|
162
|
+
return { id, mtime: fs.statSync(full).mtimeMs };
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return { id, mtime: 0 };
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
169
|
+
.map(x => x.id);
|
|
170
|
+
for (const id of sortedIds) {
|
|
171
|
+
const overviewPath = path.join(this.brainBasePath, id, '.system_generated', 'logs', 'overview.txt');
|
|
172
|
+
try {
|
|
173
|
+
if (!fs.existsSync(overviewPath))
|
|
174
|
+
continue;
|
|
175
|
+
// Only read the first 4KB to avoid huge files
|
|
176
|
+
let fd = null;
|
|
177
|
+
try {
|
|
178
|
+
fd = fs.openSync(overviewPath, 'r');
|
|
179
|
+
const buf = Buffer.alloc(4096);
|
|
180
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
181
|
+
const header = buf.slice(0, bytesRead).toString('utf-8').toLowerCase();
|
|
182
|
+
if (header.includes(needle)) {
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
if (fd !== null)
|
|
188
|
+
fs.closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Skip unreadable files
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Return the conversation ID of the most recently modified conversation
|
|
199
|
+
* that has at least one artifact. Falls back to the most recent conversation
|
|
200
|
+
* overall if none have artifacts.
|
|
201
|
+
*/
|
|
202
|
+
getLatestConversationWithArtifacts() {
|
|
203
|
+
const ids = this.listConversationIds();
|
|
204
|
+
if (ids.length === 0)
|
|
205
|
+
return null;
|
|
206
|
+
// Sort by directory mtime descending
|
|
207
|
+
const sorted = ids
|
|
208
|
+
.map((id) => {
|
|
209
|
+
const full = path.join(this.brainBasePath, id);
|
|
210
|
+
try {
|
|
211
|
+
return { id, mtime: fs.statSync(full).mtimeMs };
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return { id, mtime: 0 };
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
218
|
+
// Find the first one that has artifacts
|
|
219
|
+
for (const { id } of sorted) {
|
|
220
|
+
if (this.listArtifacts(id).length > 0)
|
|
221
|
+
return id;
|
|
222
|
+
}
|
|
223
|
+
// Nothing has artifacts โ return most recent anyway (caller handles empty list)
|
|
224
|
+
return sorted[0]?.id ?? null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Read the markdown content of a specific artifact file.
|
|
228
|
+
* Returns null if the file cannot be read.
|
|
229
|
+
*/
|
|
230
|
+
getArtifactContent(conversationId, filename) {
|
|
231
|
+
// Sanitize: prevent path traversal
|
|
232
|
+
const safe = path.basename(filename);
|
|
233
|
+
if (!safe.endsWith('.md'))
|
|
234
|
+
return null;
|
|
235
|
+
const filePath = path.join(this.brainBasePath, conversationId, safe);
|
|
236
|
+
try {
|
|
237
|
+
if (!fs.existsSync(filePath))
|
|
238
|
+
return null;
|
|
239
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
logger_1.logger.warn(`[ArtifactService] Failed to read artifact ${conversationId}/${safe}: ${err}`);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Encode conversationId and filename into a single string for Discord select menu values.
|
|
248
|
+
* We use a prefix 'art_', followed by a short slice of the conv ID, and the filename.
|
|
249
|
+
* Added a short hash of the filename to prevent collisions on long-filename truncation.
|
|
250
|
+
*/
|
|
251
|
+
static encodeSelectValue(conversationId, filename) {
|
|
252
|
+
const shortConv = conversationId.replace(/-/g, '').slice(0, 12);
|
|
253
|
+
// Simple hash of the filename
|
|
254
|
+
let hash = 0;
|
|
255
|
+
for (let i = 0; i < filename.length; i++) {
|
|
256
|
+
hash = ((hash << 5) - hash) + filename.charCodeAt(i);
|
|
257
|
+
hash |= 0; // Convert to 32bit integer
|
|
258
|
+
}
|
|
259
|
+
const shortHash = Math.abs(hash).toString(36).slice(0, 4);
|
|
260
|
+
return `art_${shortConv}_${shortHash}_${filename}`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Decode a select menu value back into conversationId and filename.
|
|
264
|
+
* Since we only have a slice of the conversationId, we must look it up
|
|
265
|
+
* in the provided list of artifacts.
|
|
266
|
+
*/
|
|
267
|
+
decodeSelectValue(value, artifacts) {
|
|
268
|
+
if (!value.startsWith('art_'))
|
|
269
|
+
return null;
|
|
270
|
+
// Format: art_CONV_HASH_FILENAME
|
|
271
|
+
const parts = value.split('_');
|
|
272
|
+
if (parts.length < 4)
|
|
273
|
+
return null;
|
|
274
|
+
const shortConv = parts[1];
|
|
275
|
+
const filename = parts.slice(3).join('_'); // Filename might contain underscores
|
|
276
|
+
// Find the matching artifact in the current list
|
|
277
|
+
const found = artifacts.find(a => a.filename === filename &&
|
|
278
|
+
a.conversationId.replace(/-/g, '').startsWith(shortConv));
|
|
279
|
+
return found ? { conversationId: found.conversationId, filename: found.filename } : null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
exports.ArtifactService = ArtifactService;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Artifacts UI โ Discord embed + select menu for the /artifacts command.
|
|
4
|
+
*
|
|
5
|
+
* Follows the same pattern as sessionPickerUi.ts.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.ARTIFACT_INLINE_BTN = exports.ARTIFACT_THREAD_BTN = exports.ARTIFACT_SELECT_ID = void 0;
|
|
9
|
+
exports.buildArtifactPickerUI = buildArtifactPickerUI;
|
|
10
|
+
exports.sendArtifactsUI = sendArtifactsUI;
|
|
11
|
+
exports.sendArtifactPickerUI = sendArtifactPickerUI;
|
|
12
|
+
const discord_js_1 = require("discord.js");
|
|
13
|
+
const artifactService_1 = require("../services/artifactService");
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** Custom ID for the artifact select menu */
|
|
18
|
+
exports.ARTIFACT_SELECT_ID = 'artifact_select';
|
|
19
|
+
/** Custom ID for the toggle buttons */
|
|
20
|
+
exports.ARTIFACT_THREAD_BTN = 'artifact_mode_thread';
|
|
21
|
+
exports.ARTIFACT_INLINE_BTN = 'artifact_mode_inline';
|
|
22
|
+
/** Discord select menu option limit */
|
|
23
|
+
const MAX_SELECT_OPTIONS = 25;
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function truncate(str, max) {
|
|
28
|
+
if (str.length <= max)
|
|
29
|
+
return str;
|
|
30
|
+
return str.slice(0, max - 1) + 'โฆ';
|
|
31
|
+
}
|
|
32
|
+
function formatOptionDescription(artifact) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
parts.push((0, artifactService_1.artifactTypeLabel)(artifact.artifactType));
|
|
35
|
+
if (artifact.summary) {
|
|
36
|
+
parts.push(truncate(artifact.summary, 60));
|
|
37
|
+
}
|
|
38
|
+
const combined = parts.join(' ยท ');
|
|
39
|
+
// Discord limits descriptions to 100 chars
|
|
40
|
+
return combined.length > 0 ? truncate(combined, 100) : undefined;
|
|
41
|
+
}
|
|
42
|
+
function formatUpdatedAt(iso) {
|
|
43
|
+
if (!iso)
|
|
44
|
+
return '';
|
|
45
|
+
try {
|
|
46
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
47
|
+
month: 'short',
|
|
48
|
+
day: 'numeric',
|
|
49
|
+
hour: '2-digit',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Discord-specific builder
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Build the artifact picker embed + select menu components.
|
|
62
|
+
*/
|
|
63
|
+
function buildArtifactPickerUI(artifacts, conversationId, renderMode = 'thread') {
|
|
64
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
65
|
+
.setTitle('๐ Artifacts')
|
|
66
|
+
.setColor(0x5865F2)
|
|
67
|
+
.setTimestamp();
|
|
68
|
+
if (artifacts.length === 0) {
|
|
69
|
+
embed.setDescription('No artifacts found for the active session.');
|
|
70
|
+
return { embeds: [embed], components: [] };
|
|
71
|
+
}
|
|
72
|
+
const displayId = conversationId
|
|
73
|
+
? conversationId.slice(0, 8) + 'โฆ'
|
|
74
|
+
: 'current session';
|
|
75
|
+
embed.setDescription(`**${artifacts.length}** artifact(s) found (conversation \`${displayId}\`)\n` +
|
|
76
|
+
'Select one to render its content below.');
|
|
77
|
+
const fields = artifacts.map((a) => ({
|
|
78
|
+
name: a.filename,
|
|
79
|
+
value: [
|
|
80
|
+
(0, artifactService_1.artifactTypeLabel)(a.artifactType),
|
|
81
|
+
a.summary ? `*${truncate(a.summary, 120)}*` : '',
|
|
82
|
+
a.updatedAt ? `๐ v${a.version ?? '?'} ยท ${formatUpdatedAt(a.updatedAt)}` : '',
|
|
83
|
+
].filter(Boolean).join('\n'),
|
|
84
|
+
inline: false,
|
|
85
|
+
}));
|
|
86
|
+
// Add summary fields (max 10 to avoid embed limit)
|
|
87
|
+
embed.addFields(...fields.slice(0, 10));
|
|
88
|
+
const pageItems = artifacts.slice(0, MAX_SELECT_OPTIONS);
|
|
89
|
+
const options = pageItems.map((a) => ({
|
|
90
|
+
label: truncate(a.filename, 100),
|
|
91
|
+
value: artifactService_1.ArtifactService.encodeSelectValue(a.conversationId, a.filename),
|
|
92
|
+
description: formatOptionDescription(a),
|
|
93
|
+
}));
|
|
94
|
+
const selectMenu = new discord_js_1.StringSelectMenuBuilder()
|
|
95
|
+
.setCustomId(exports.ARTIFACT_SELECT_ID)
|
|
96
|
+
.setPlaceholder('Select an artifact to viewโฆ')
|
|
97
|
+
.addOptions(options);
|
|
98
|
+
const components = [
|
|
99
|
+
new discord_js_1.ActionRowBuilder().addComponents(selectMenu),
|
|
100
|
+
];
|
|
101
|
+
// Add toggle button row
|
|
102
|
+
const toggleButton = new discord_js_1.ButtonBuilder();
|
|
103
|
+
if (renderMode === 'thread') {
|
|
104
|
+
toggleButton
|
|
105
|
+
.setCustomId(`${exports.ARTIFACT_INLINE_BTN}:${conversationId || ''}`)
|
|
106
|
+
.setLabel('๐ฌ Switch to Inline')
|
|
107
|
+
.setStyle(discord_js_1.ButtonStyle.Secondary)
|
|
108
|
+
.setEmoji('๐ฌ');
|
|
109
|
+
embed.setFooter({ text: 'Output: Thread (one thread per file)' });
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
toggleButton
|
|
113
|
+
.setCustomId(`${exports.ARTIFACT_THREAD_BTN}:${conversationId || ''}`)
|
|
114
|
+
.setLabel('๐ Switch to Thread')
|
|
115
|
+
.setStyle(discord_js_1.ButtonStyle.Primary)
|
|
116
|
+
.setEmoji('๐');
|
|
117
|
+
embed.setFooter({ text: 'Output: Inline' });
|
|
118
|
+
}
|
|
119
|
+
components.push(new discord_js_1.ActionRowBuilder().addComponents(toggleButton));
|
|
120
|
+
return { embeds: [embed], components };
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Interaction sender
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Send the artifacts picker UI as an editReply to a slash command interaction.
|
|
127
|
+
*/
|
|
128
|
+
async function sendArtifactsUI(interaction, artifacts, conversationId, renderMode = 'thread') {
|
|
129
|
+
const { embeds, components } = buildArtifactPickerUI(artifacts, conversationId, renderMode);
|
|
130
|
+
await interaction.editReply({ embeds, components });
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Higher-level helper to send the artifact picker UI,
|
|
134
|
+
* automatically discovering artifacts for the current channel.
|
|
135
|
+
*/
|
|
136
|
+
async function sendArtifactPickerUI(interaction, deps, edit = false, resolvedConversationId) {
|
|
137
|
+
const artifactService = deps.artifactService || new artifactService_1.ArtifactService();
|
|
138
|
+
let conversationId = resolvedConversationId;
|
|
139
|
+
if (!conversationId) {
|
|
140
|
+
const session = deps.chatSessionRepo?.findByChannelId(interaction.channelId);
|
|
141
|
+
if (session) {
|
|
142
|
+
conversationId = session.conversationId ?? null;
|
|
143
|
+
// Fallback to title matching if ID is not in DB or doesn't match a real folder
|
|
144
|
+
if (!conversationId || !artifactService.listArtifacts(conversationId).length) {
|
|
145
|
+
const sessionTitle = session.displayName?.trim() ?? '';
|
|
146
|
+
const matchedId = sessionTitle ? artifactService.findConversationByTitle(sessionTitle) : null;
|
|
147
|
+
if (matchedId)
|
|
148
|
+
conversationId = matchedId;
|
|
149
|
+
}
|
|
150
|
+
// Note: We do NOT fallback to getLatestConversationWithArtifacts() for managed sessions
|
|
151
|
+
// to avoid showing artifacts from other projects/chats.
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Final fallback: latest overall (only for non-session channels)
|
|
155
|
+
conversationId = artifactService.getLatestConversationWithArtifacts();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!conversationId) {
|
|
159
|
+
const payload = { content: '๐ No artifacts found for this session.', components: [], ephemeral: true };
|
|
160
|
+
if (edit || interaction.deferred || interaction.replied)
|
|
161
|
+
await interaction.editReply(payload);
|
|
162
|
+
else
|
|
163
|
+
await interaction.reply(payload);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// 2. List artifacts
|
|
167
|
+
const artifacts = artifactService.listArtifacts(conversationId);
|
|
168
|
+
if (artifacts.length === 0) {
|
|
169
|
+
const payload = { content: '๐ No artifacts found in this conversation.', components: [], ephemeral: true };
|
|
170
|
+
if (edit || interaction.deferred || interaction.replied)
|
|
171
|
+
await interaction.editReply(payload);
|
|
172
|
+
else
|
|
173
|
+
await interaction.reply(payload);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// 3. Get user render mode pref
|
|
177
|
+
const renderMode = deps.userPrefRepo?.getArtifactRenderMode(interaction.user.id) ?? 'thread';
|
|
178
|
+
// 4. Build and send
|
|
179
|
+
const { embeds, components } = buildArtifactPickerUI(artifacts, conversationId, renderMode);
|
|
180
|
+
const payload = { embeds, components, ephemeral: true };
|
|
181
|
+
if (edit || interaction.deferred || interaction.replied)
|
|
182
|
+
await interaction.editReply(payload);
|
|
183
|
+
else
|
|
184
|
+
await interaction.reply(payload);
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazy-gravity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Control Antigravity from anywhere โ a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"ts-jest": "^29.4.6",
|
|
73
73
|
"ts-morph": "^28.0.0",
|
|
74
74
|
"ts-node": "^10.9.2",
|
|
75
|
+
"ts-node-dev": "^2.0.0",
|
|
75
76
|
"typescript": "^5.9.3",
|
|
76
77
|
"undici": "^8.0.3"
|
|
77
78
|
}
|