kimaki 0.4.22 → 0.4.24
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/channel-management.js +92 -0
- package/dist/cli.js +9 -1
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -1
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +9 -1
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3671
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { Part, FilePartInput } from '@opencode-ai/sdk'
|
|
2
|
+
import type { Message } from 'discord.js'
|
|
3
|
+
import { createLogger } from './logger.js'
|
|
4
|
+
|
|
5
|
+
const logger = createLogger('FORMATTING')
|
|
6
|
+
|
|
7
|
+
export const TEXT_MIME_TYPES = [
|
|
8
|
+
'text/',
|
|
9
|
+
'application/json',
|
|
10
|
+
'application/xml',
|
|
11
|
+
'application/javascript',
|
|
12
|
+
'application/typescript',
|
|
13
|
+
'application/x-yaml',
|
|
14
|
+
'application/toml',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export function isTextMimeType(contentType: string | null): boolean {
|
|
18
|
+
if (!contentType) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getTextAttachments(message: Message): Promise<string> {
|
|
25
|
+
const textAttachments = Array.from(message.attachments.values()).filter(
|
|
26
|
+
(attachment) => isTextMimeType(attachment.contentType),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if (textAttachments.length === 0) {
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const textContents = await Promise.all(
|
|
34
|
+
textAttachments.map(async (attachment) => {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(attachment.url)
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
|
|
39
|
+
}
|
|
40
|
+
const text = await response.text()
|
|
41
|
+
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
44
|
+
return `<attachment filename="${attachment.name}" error="${errMsg}" />`
|
|
45
|
+
}
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return textContents.join('\n\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFileAttachments(message: Message): FilePartInput[] {
|
|
53
|
+
const fileAttachments = Array.from(message.attachments.values()).filter(
|
|
54
|
+
(attachment) => {
|
|
55
|
+
const contentType = attachment.contentType || ''
|
|
56
|
+
return (
|
|
57
|
+
contentType.startsWith('image/') || contentType === 'application/pdf'
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return fileAttachments.map((attachment) => ({
|
|
63
|
+
type: 'file' as const,
|
|
64
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
65
|
+
filename: attachment.name,
|
|
66
|
+
url: attachment.url,
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getToolSummaryText(part: Part): string {
|
|
71
|
+
if (part.type !== 'tool') return ''
|
|
72
|
+
|
|
73
|
+
if (part.tool === 'edit') {
|
|
74
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
75
|
+
const newString = (part.state.input?.newString as string) || ''
|
|
76
|
+
const oldString = (part.state.input?.oldString as string) || ''
|
|
77
|
+
const added = newString.split('\n').length
|
|
78
|
+
const removed = oldString.split('\n').length
|
|
79
|
+
const fileName = filePath.split('/').pop() || ''
|
|
80
|
+
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (part.tool === 'write') {
|
|
84
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
85
|
+
const content = (part.state.input?.content as string) || ''
|
|
86
|
+
const lines = content.split('\n').length
|
|
87
|
+
const fileName = filePath.split('/').pop() || ''
|
|
88
|
+
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (part.tool === 'webfetch') {
|
|
92
|
+
const url = (part.state.input?.url as string) || ''
|
|
93
|
+
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
|
|
94
|
+
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (part.tool === 'read') {
|
|
98
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
99
|
+
const fileName = filePath.split('/').pop() || ''
|
|
100
|
+
return fileName ? `*${fileName}*` : ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (part.tool === 'list') {
|
|
104
|
+
const path = (part.state.input?.path as string) || ''
|
|
105
|
+
const dirName = path.split('/').pop() || path
|
|
106
|
+
return dirName ? `*${dirName}*` : ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (part.tool === 'glob') {
|
|
110
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
111
|
+
return pattern ? `*${pattern}*` : ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (part.tool === 'grep') {
|
|
115
|
+
const pattern = (part.state.input?.pattern as string) || ''
|
|
116
|
+
return pattern ? `*${pattern}*` : ''
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
120
|
+
return ''
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (part.tool === 'task') {
|
|
124
|
+
const description = (part.state.input?.description as string) || ''
|
|
125
|
+
return description ? `_${description}_` : ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (part.tool === 'skill') {
|
|
129
|
+
const name = (part.state.input?.name as string) || ''
|
|
130
|
+
return name ? `_${name}_` : ''
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!part.state.input) return ''
|
|
134
|
+
|
|
135
|
+
const inputFields = Object.entries(part.state.input)
|
|
136
|
+
.map(([key, value]) => {
|
|
137
|
+
if (value === null || value === undefined) return null
|
|
138
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
|
|
139
|
+
const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
|
|
140
|
+
return `${key}: ${truncatedValue}`
|
|
141
|
+
})
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
|
|
144
|
+
if (inputFields.length === 0) return ''
|
|
145
|
+
|
|
146
|
+
return `(${inputFields.join(', ')})`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatTodoList(part: Part): string {
|
|
150
|
+
if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
|
|
151
|
+
const todos =
|
|
152
|
+
(part.state.input?.todos as {
|
|
153
|
+
content: string
|
|
154
|
+
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
155
|
+
}[]) || []
|
|
156
|
+
const activeIndex = todos.findIndex((todo) => {
|
|
157
|
+
return todo.status === 'in_progress'
|
|
158
|
+
})
|
|
159
|
+
const activeTodo = todos[activeIndex]
|
|
160
|
+
if (activeIndex === -1 || !activeTodo) return ''
|
|
161
|
+
return `${activeIndex + 1}. **${activeTodo.content}**`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function formatPart(part: Part): string {
|
|
165
|
+
if (part.type === 'text') {
|
|
166
|
+
return part.text || ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (part.type === 'reasoning') {
|
|
170
|
+
if (!part.text?.trim()) return ''
|
|
171
|
+
return `◼︎ thinking`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (part.type === 'file') {
|
|
175
|
+
return `📄 ${part.filename || 'File'}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
179
|
+
return ''
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (part.type === 'agent') {
|
|
183
|
+
return `◼︎ agent ${part.id}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (part.type === 'snapshot') {
|
|
187
|
+
return `◼︎ snapshot ${part.snapshot}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (part.type === 'tool') {
|
|
191
|
+
if (part.tool === 'todowrite') {
|
|
192
|
+
return formatTodoList(part)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (part.state.status === 'pending') {
|
|
196
|
+
return ''
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const summaryText = getToolSummaryText(part)
|
|
200
|
+
const stateTitle = 'title' in part.state ? part.state.title : undefined
|
|
201
|
+
|
|
202
|
+
let toolTitle = ''
|
|
203
|
+
if (part.state.status === 'error') {
|
|
204
|
+
toolTitle = part.state.error || 'error'
|
|
205
|
+
} else if (part.tool === 'bash') {
|
|
206
|
+
const command = (part.state.input?.command as string) || ''
|
|
207
|
+
const description = (part.state.input?.description as string) || ''
|
|
208
|
+
const isSingleLine = !command.includes('\n')
|
|
209
|
+
const hasUnderscores = command.includes('_')
|
|
210
|
+
if (isSingleLine && !hasUnderscores && command.length <= 50) {
|
|
211
|
+
toolTitle = `_${command}_`
|
|
212
|
+
} else if (description) {
|
|
213
|
+
toolTitle = `_${description}_`
|
|
214
|
+
} else if (stateTitle) {
|
|
215
|
+
toolTitle = `_${stateTitle}_`
|
|
216
|
+
}
|
|
217
|
+
} else if (stateTitle) {
|
|
218
|
+
toolTitle = `_${stateTitle}_`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎'
|
|
222
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
logger.warn('Unknown part type:', part)
|
|
226
|
+
return ''
|
|
227
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChatInputCommandInteraction,
|
|
3
|
+
StringSelectMenuInteraction,
|
|
4
|
+
StringSelectMenuBuilder,
|
|
5
|
+
ActionRowBuilder,
|
|
6
|
+
ChannelType,
|
|
7
|
+
type ThreadChannel,
|
|
8
|
+
type TextChannel,
|
|
9
|
+
} from 'discord.js'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from './database.js'
|
|
12
|
+
import { initializeOpencodeForDirectory } from './opencode.js'
|
|
13
|
+
import { resolveTextChannel, getKimakiMetadata } from './discord-utils.js'
|
|
14
|
+
import { createLogger } from './logger.js'
|
|
15
|
+
|
|
16
|
+
const modelLogger = createLogger('MODEL')
|
|
17
|
+
|
|
18
|
+
// Store context by hash to avoid customId length limits (Discord max: 100 chars)
|
|
19
|
+
const pendingModelContexts = new Map<string, {
|
|
20
|
+
dir: string
|
|
21
|
+
channelId: string
|
|
22
|
+
sessionId?: string
|
|
23
|
+
isThread: boolean
|
|
24
|
+
providerId?: string
|
|
25
|
+
providerName?: string
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
type ProviderInfo = {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
models: Record<
|
|
32
|
+
string,
|
|
33
|
+
{
|
|
34
|
+
id: string
|
|
35
|
+
name: string
|
|
36
|
+
release_date: string
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle the /model slash command.
|
|
43
|
+
* Shows a select menu with available providers.
|
|
44
|
+
*/
|
|
45
|
+
export async function handleModelCommand({
|
|
46
|
+
interaction,
|
|
47
|
+
appId,
|
|
48
|
+
}: {
|
|
49
|
+
interaction: ChatInputCommandInteraction
|
|
50
|
+
appId: string
|
|
51
|
+
}): Promise<void> {
|
|
52
|
+
modelLogger.log('[MODEL] handleModelCommand called')
|
|
53
|
+
|
|
54
|
+
// Defer reply immediately to avoid 3-second timeout
|
|
55
|
+
await interaction.deferReply({ ephemeral: true })
|
|
56
|
+
modelLogger.log('[MODEL] Deferred reply')
|
|
57
|
+
|
|
58
|
+
// Ensure migrations are run
|
|
59
|
+
runModelMigrations()
|
|
60
|
+
|
|
61
|
+
const channel = interaction.channel
|
|
62
|
+
|
|
63
|
+
if (!channel) {
|
|
64
|
+
await interaction.editReply({
|
|
65
|
+
content: 'This command can only be used in a channel',
|
|
66
|
+
})
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Determine if we're in a thread or text channel
|
|
71
|
+
const isThread = [
|
|
72
|
+
ChannelType.PublicThread,
|
|
73
|
+
ChannelType.PrivateThread,
|
|
74
|
+
ChannelType.AnnouncementThread,
|
|
75
|
+
].includes(channel.type)
|
|
76
|
+
|
|
77
|
+
let projectDirectory: string | undefined
|
|
78
|
+
let channelAppId: string | undefined
|
|
79
|
+
let targetChannelId: string
|
|
80
|
+
let sessionId: string | undefined
|
|
81
|
+
|
|
82
|
+
if (isThread) {
|
|
83
|
+
const thread = channel as ThreadChannel
|
|
84
|
+
const textChannel = await resolveTextChannel(thread)
|
|
85
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
86
|
+
projectDirectory = metadata.projectDirectory
|
|
87
|
+
channelAppId = metadata.channelAppId
|
|
88
|
+
targetChannelId = textChannel?.id || channel.id
|
|
89
|
+
|
|
90
|
+
// Get session ID for this thread
|
|
91
|
+
const row = getDatabase()
|
|
92
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
93
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
94
|
+
sessionId = row?.session_id
|
|
95
|
+
} else if (channel.type === ChannelType.GuildText) {
|
|
96
|
+
const textChannel = channel as TextChannel
|
|
97
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
98
|
+
projectDirectory = metadata.projectDirectory
|
|
99
|
+
channelAppId = metadata.channelAppId
|
|
100
|
+
targetChannelId = channel.id
|
|
101
|
+
} else {
|
|
102
|
+
await interaction.editReply({
|
|
103
|
+
content: 'This command can only be used in text channels or threads',
|
|
104
|
+
})
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (channelAppId && channelAppId !== appId) {
|
|
109
|
+
await interaction.editReply({
|
|
110
|
+
content: 'This channel is not configured for this bot',
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!projectDirectory) {
|
|
116
|
+
await interaction.editReply({
|
|
117
|
+
content: 'This channel is not configured with a project directory',
|
|
118
|
+
})
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
124
|
+
|
|
125
|
+
const providersResponse = await getClient().provider.list({
|
|
126
|
+
query: { directory: projectDirectory },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
if (!providersResponse.data) {
|
|
130
|
+
await interaction.editReply({
|
|
131
|
+
content: 'Failed to fetch providers',
|
|
132
|
+
})
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { all: allProviders, connected } = providersResponse.data
|
|
137
|
+
|
|
138
|
+
// Filter to only connected providers (have credentials)
|
|
139
|
+
const availableProviders = allProviders.filter((p) => {
|
|
140
|
+
return connected.includes(p.id)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if (availableProviders.length === 0) {
|
|
144
|
+
await interaction.editReply({
|
|
145
|
+
content:
|
|
146
|
+
'No providers with credentials found. Use `/connect` in OpenCode TUI to add provider credentials.',
|
|
147
|
+
})
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Store context with a short hash key to avoid customId length limits
|
|
152
|
+
const context = {
|
|
153
|
+
dir: projectDirectory,
|
|
154
|
+
channelId: targetChannelId,
|
|
155
|
+
sessionId: sessionId,
|
|
156
|
+
isThread: isThread,
|
|
157
|
+
}
|
|
158
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
159
|
+
pendingModelContexts.set(contextHash, context)
|
|
160
|
+
|
|
161
|
+
const options = availableProviders.slice(0, 25).map((provider) => {
|
|
162
|
+
const modelCount = Object.keys(provider.models || {}).length
|
|
163
|
+
return {
|
|
164
|
+
label: provider.name.slice(0, 100),
|
|
165
|
+
value: provider.id,
|
|
166
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
171
|
+
.setCustomId(`model_provider:${contextHash}`)
|
|
172
|
+
.setPlaceholder('Select a provider')
|
|
173
|
+
.addOptions(options)
|
|
174
|
+
|
|
175
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
176
|
+
|
|
177
|
+
await interaction.editReply({
|
|
178
|
+
content: '**Set Model Preference**\nSelect a provider:',
|
|
179
|
+
components: [actionRow],
|
|
180
|
+
})
|
|
181
|
+
} catch (error) {
|
|
182
|
+
modelLogger.error('Error loading providers:', error)
|
|
183
|
+
await interaction.editReply({
|
|
184
|
+
content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handle the provider select menu interaction.
|
|
191
|
+
* Shows a second select menu with models for the chosen provider.
|
|
192
|
+
*/
|
|
193
|
+
export async function handleProviderSelectMenu(
|
|
194
|
+
interaction: StringSelectMenuInteraction
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const customId = interaction.customId
|
|
197
|
+
|
|
198
|
+
if (!customId.startsWith('model_provider:')) {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Defer update immediately to avoid timeout
|
|
203
|
+
await interaction.deferUpdate()
|
|
204
|
+
|
|
205
|
+
const contextHash = customId.replace('model_provider:', '')
|
|
206
|
+
const context = pendingModelContexts.get(contextHash)
|
|
207
|
+
|
|
208
|
+
if (!context) {
|
|
209
|
+
await interaction.editReply({
|
|
210
|
+
content: 'Selection expired. Please run /model again.',
|
|
211
|
+
components: [],
|
|
212
|
+
})
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const selectedProviderId = interaction.values[0]
|
|
217
|
+
if (!selectedProviderId) {
|
|
218
|
+
await interaction.editReply({
|
|
219
|
+
content: 'No provider selected',
|
|
220
|
+
components: [],
|
|
221
|
+
})
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const getClient = await initializeOpencodeForDirectory(context.dir)
|
|
227
|
+
|
|
228
|
+
const providersResponse = await getClient().provider.list({
|
|
229
|
+
query: { directory: context.dir },
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (!providersResponse.data) {
|
|
233
|
+
await interaction.editReply({
|
|
234
|
+
content: 'Failed to fetch providers',
|
|
235
|
+
components: [],
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId)
|
|
241
|
+
|
|
242
|
+
if (!provider) {
|
|
243
|
+
await interaction.editReply({
|
|
244
|
+
content: 'Provider not found',
|
|
245
|
+
components: [],
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const models = Object.entries(provider.models || {})
|
|
251
|
+
.map(([modelId, model]) => ({
|
|
252
|
+
id: modelId,
|
|
253
|
+
name: model.name,
|
|
254
|
+
releaseDate: model.release_date,
|
|
255
|
+
}))
|
|
256
|
+
// Sort by release date descending (most recent first)
|
|
257
|
+
.sort((a, b) => {
|
|
258
|
+
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
|
|
259
|
+
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
|
|
260
|
+
return dateB - dateA
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if (models.length === 0) {
|
|
264
|
+
await interaction.editReply({
|
|
265
|
+
content: `No models available for ${provider.name}`,
|
|
266
|
+
components: [],
|
|
267
|
+
})
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Take first 25 models (most recent since sorted descending)
|
|
272
|
+
const recentModels = models.slice(0, 25)
|
|
273
|
+
|
|
274
|
+
// Update context with provider info and reuse the same hash
|
|
275
|
+
context.providerId = selectedProviderId
|
|
276
|
+
context.providerName = provider.name
|
|
277
|
+
pendingModelContexts.set(contextHash, context)
|
|
278
|
+
|
|
279
|
+
const options = recentModels.map((model) => {
|
|
280
|
+
const dateStr = model.releaseDate
|
|
281
|
+
? new Date(model.releaseDate).toLocaleDateString()
|
|
282
|
+
: 'Unknown date'
|
|
283
|
+
return {
|
|
284
|
+
label: model.name.slice(0, 100),
|
|
285
|
+
value: model.id,
|
|
286
|
+
description: dateStr.slice(0, 100),
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
291
|
+
.setCustomId(`model_select:${contextHash}`)
|
|
292
|
+
.setPlaceholder('Select a model')
|
|
293
|
+
.addOptions(options)
|
|
294
|
+
|
|
295
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
296
|
+
|
|
297
|
+
await interaction.editReply({
|
|
298
|
+
content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
|
|
299
|
+
components: [actionRow],
|
|
300
|
+
})
|
|
301
|
+
} catch (error) {
|
|
302
|
+
modelLogger.error('Error loading models:', error)
|
|
303
|
+
await interaction.editReply({
|
|
304
|
+
content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
305
|
+
components: [],
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Handle the model select menu interaction.
|
|
312
|
+
* Stores the model preference in the database.
|
|
313
|
+
*/
|
|
314
|
+
export async function handleModelSelectMenu(
|
|
315
|
+
interaction: StringSelectMenuInteraction
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
const customId = interaction.customId
|
|
318
|
+
|
|
319
|
+
if (!customId.startsWith('model_select:')) {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Defer update immediately
|
|
324
|
+
await interaction.deferUpdate()
|
|
325
|
+
|
|
326
|
+
const contextHash = customId.replace('model_select:', '')
|
|
327
|
+
const context = pendingModelContexts.get(contextHash)
|
|
328
|
+
|
|
329
|
+
if (!context || !context.providerId || !context.providerName) {
|
|
330
|
+
await interaction.editReply({
|
|
331
|
+
content: 'Selection expired. Please run /model again.',
|
|
332
|
+
components: [],
|
|
333
|
+
})
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const selectedModelId = interaction.values[0]
|
|
338
|
+
if (!selectedModelId) {
|
|
339
|
+
await interaction.editReply({
|
|
340
|
+
content: 'No model selected',
|
|
341
|
+
components: [],
|
|
342
|
+
})
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Build full model ID: provider_id/model_id
|
|
347
|
+
const fullModelId = `${context.providerId}/${selectedModelId}`
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
// Store in appropriate table based on context
|
|
351
|
+
if (context.isThread && context.sessionId) {
|
|
352
|
+
// Store for session
|
|
353
|
+
setSessionModel(context.sessionId, fullModelId)
|
|
354
|
+
modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
|
|
355
|
+
|
|
356
|
+
await interaction.editReply({
|
|
357
|
+
content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
|
|
358
|
+
components: [],
|
|
359
|
+
})
|
|
360
|
+
} else {
|
|
361
|
+
// Store for channel
|
|
362
|
+
setChannelModel(context.channelId, fullModelId)
|
|
363
|
+
modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`)
|
|
364
|
+
|
|
365
|
+
await interaction.editReply({
|
|
366
|
+
content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\nAll new sessions in this channel will use this model.`,
|
|
367
|
+
components: [],
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Clean up the context from memory
|
|
372
|
+
pendingModelContexts.delete(contextHash)
|
|
373
|
+
} catch (error) {
|
|
374
|
+
modelLogger.error('Error saving model preference:', error)
|
|
375
|
+
await interaction.editReply({
|
|
376
|
+
content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
377
|
+
components: [],
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|