@mseep/obsidian-agent-client 0.10.6
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/.claude/hooks/gh-setup.sh +49 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/release-notes/SKILL.md +331 -0
- package/.editorconfig +10 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
- package/.github/copilot-instructions.md +45 -0
- package/.github/pull_request_template.md +32 -0
- package/.github/workflows/ci.yaml +25 -0
- package/.github/workflows/docs.yml +58 -0
- package/.github/workflows/relay_to_openclaw.yml +59 -0
- package/.github/workflows/release.yaml +45 -0
- package/.prettierignore +10 -0
- package/.prettierrc +13 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +37 -0
- package/.zed/settings.json +42 -0
- package/AGENTS.md +330 -0
- package/ARCHITECTURE.md +390 -0
- package/CONTRIBUTING.md +216 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.ja.md +121 -0
- package/README.md +125 -0
- package/docs/.vitepress/config.mts +124 -0
- package/docs/.vitepress/theme/custom.css +111 -0
- package/docs/.vitepress/theme/index.ts +4 -0
- package/docs/agent-setup/claude-code.md +84 -0
- package/docs/agent-setup/codex.md +76 -0
- package/docs/agent-setup/custom-agents.md +67 -0
- package/docs/agent-setup/gemini-cli.md +99 -0
- package/docs/agent-setup/index.md +34 -0
- package/docs/announcements/gemini-cli-deprecation.md +73 -0
- package/docs/getting-started/index.md +78 -0
- package/docs/getting-started/quick-start.md +38 -0
- package/docs/help/faq.md +181 -0
- package/docs/help/troubleshooting.md +221 -0
- package/docs/index.md +63 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/demo.mp4 +0 -0
- package/docs/public/favicon-16x16.png +0 -0
- package/docs/public/favicon-32x32.png +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/images/editing.webp +0 -0
- package/docs/public/images/export.webp +0 -0
- package/docs/public/images/floating-chat-button.webp +0 -0
- package/docs/public/images/floating-chat-instance-menu.webp +0 -0
- package/docs/public/images/floating-chat-view.webp +0 -0
- package/docs/public/images/mode-selection.webp +0 -0
- package/docs/public/images/model-selection.webp +0 -0
- package/docs/public/images/multi-session.webp +0 -0
- package/docs/public/images/remove-image.webp +0 -0
- package/docs/public/images/ribbon-icon.webp +0 -0
- package/docs/public/images/selection-context.gif +0 -0
- package/docs/public/images/sending-images.webp +0 -0
- package/docs/public/images/sending-messages.webp +0 -0
- package/docs/public/images/session-history-button.webp +0 -0
- package/docs/public/images/slash-commands-1.webp +0 -0
- package/docs/public/images/slash-commands-2.webp +0 -0
- package/docs/public/images/switch-agent.webp +0 -0
- package/docs/public/images/switch-default-agent.webp +0 -0
- package/docs/public/images/temporary-disable.gif +0 -0
- package/docs/reference/acp-support.md +110 -0
- package/docs/usage/chat-export.md +80 -0
- package/docs/usage/commands.md +51 -0
- package/docs/usage/context-files.md +57 -0
- package/docs/usage/editing.md +69 -0
- package/docs/usage/floating-chat.md +84 -0
- package/docs/usage/index.md +97 -0
- package/docs/usage/mcp-tools.md +33 -0
- package/docs/usage/mentions.md +70 -0
- package/docs/usage/mode-selection.md +28 -0
- package/docs/usage/model-selection.md +32 -0
- package/docs/usage/multi-session.md +68 -0
- package/docs/usage/sending-images.md +64 -0
- package/docs/usage/session-history.md +91 -0
- package/docs/usage/slash-commands.md +44 -0
- package/esbuild.config.mjs +49 -0
- package/eslint.config.mjs +25 -0
- package/main.js +228 -0
- package/manifest.json +11 -0
- package/package.json +52 -0
- package/src/acp/acp-client.ts +921 -0
- package/src/acp/acp-handler.ts +252 -0
- package/src/acp/permission-handler.ts +282 -0
- package/src/acp/terminal-handler.ts +264 -0
- package/src/acp/type-converter.ts +272 -0
- package/src/hooks/useAgent.ts +250 -0
- package/src/hooks/useAgentMessages.ts +470 -0
- package/src/hooks/useAgentSession.ts +544 -0
- package/src/hooks/useChatActions.ts +400 -0
- package/src/hooks/useHistoryModal.ts +219 -0
- package/src/hooks/useSessionHistory.ts +863 -0
- package/src/hooks/useSettings.ts +19 -0
- package/src/hooks/useSuggestions.ts +342 -0
- package/src/main.ts +9 -0
- package/src/plugin.ts +1126 -0
- package/src/services/chat-exporter.ts +552 -0
- package/src/services/message-sender.ts +755 -0
- package/src/services/message-state.ts +375 -0
- package/src/services/session-helpers.ts +211 -0
- package/src/services/session-state.ts +130 -0
- package/src/services/session-storage.ts +267 -0
- package/src/services/settings-normalizer.ts +255 -0
- package/src/services/settings-service.ts +285 -0
- package/src/services/update-checker.ts +128 -0
- package/src/services/vault-service.ts +558 -0
- package/src/services/view-registry.ts +345 -0
- package/src/types/agent.ts +92 -0
- package/src/types/chat.ts +351 -0
- package/src/types/errors.ts +136 -0
- package/src/types/obsidian-internals.d.ts +14 -0
- package/src/types/session.ts +731 -0
- package/src/ui/ChangeDirectoryModal.ts +137 -0
- package/src/ui/ChatContext.ts +25 -0
- package/src/ui/ChatHeader.tsx +295 -0
- package/src/ui/ChatPanel.tsx +1162 -0
- package/src/ui/ChatView.tsx +348 -0
- package/src/ui/ErrorBanner.tsx +104 -0
- package/src/ui/FloatingButton.tsx +351 -0
- package/src/ui/FloatingChatView.tsx +531 -0
- package/src/ui/InputArea.tsx +1107 -0
- package/src/ui/InputToolbar.tsx +371 -0
- package/src/ui/MessageBubble.tsx +442 -0
- package/src/ui/MessageList.tsx +265 -0
- package/src/ui/PermissionBanner.tsx +61 -0
- package/src/ui/SessionHistoryModal.tsx +821 -0
- package/src/ui/SettingsTab.ts +1337 -0
- package/src/ui/SuggestionPopup.tsx +138 -0
- package/src/ui/TerminalBlock.tsx +107 -0
- package/src/ui/ToolCallBlock.tsx +456 -0
- package/src/ui/shared/AttachmentStrip.tsx +57 -0
- package/src/ui/shared/IconButton.tsx +55 -0
- package/src/ui/shared/MarkdownRenderer.tsx +103 -0
- package/src/ui/view-host.ts +56 -0
- package/src/utils/error-utils.ts +274 -0
- package/src/utils/logger.ts +44 -0
- package/src/utils/mention-parser.ts +129 -0
- package/src/utils/paths.ts +246 -0
- package/src/utils/platform.ts +425 -0
- package/styles.css +2322 -0
- package/tsconfig.json +18 -0
- package/version-bump.mjs +18 -0
- package/versions.json +3 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import type AgentClientPlugin from "../plugin";
|
|
2
|
+
import type { ChatMessage, MessageContent } from "../types/chat";
|
|
3
|
+
import { getLogger, Logger } from "../utils/logger";
|
|
4
|
+
import { TFile } from "obsidian";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Context for content conversion, tracking state across messages.
|
|
8
|
+
*/
|
|
9
|
+
interface ConvertContext {
|
|
10
|
+
/** Path of the export markdown file */
|
|
11
|
+
exportFilePath: string;
|
|
12
|
+
/** Counter for image numbering */
|
|
13
|
+
imageIndex: number;
|
|
14
|
+
/** Whether to include images in export */
|
|
15
|
+
includeImages: boolean;
|
|
16
|
+
/** Where to save images */
|
|
17
|
+
imageLocation: "obsidian" | "custom" | "base64";
|
|
18
|
+
/** Custom folder for images */
|
|
19
|
+
imageCustomFolder: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ChatExporter {
|
|
23
|
+
private logger: Logger;
|
|
24
|
+
|
|
25
|
+
constructor(private plugin: AgentClientPlugin) {
|
|
26
|
+
this.logger = getLogger();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async exportToMarkdown(
|
|
30
|
+
messages: ChatMessage[],
|
|
31
|
+
agentLabel: string,
|
|
32
|
+
agentId: string,
|
|
33
|
+
sessionId: string,
|
|
34
|
+
sessionCreatedAt: Date,
|
|
35
|
+
openFile = true,
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const settings = this.plugin.settings.exportSettings;
|
|
38
|
+
|
|
39
|
+
// Use first message timestamp if available, fallback to session creation time
|
|
40
|
+
const effectiveTimestamp =
|
|
41
|
+
messages.length > 0 ? messages[0].timestamp : sessionCreatedAt;
|
|
42
|
+
|
|
43
|
+
const baseFileName = this.generateFileName(effectiveTimestamp);
|
|
44
|
+
const folderPath = settings.defaultFolder || "Agent Client";
|
|
45
|
+
|
|
46
|
+
// Create folder if it doesn't exist
|
|
47
|
+
await this.ensureFolderExists(folderPath);
|
|
48
|
+
|
|
49
|
+
// Resolve file path considering session ID conflicts
|
|
50
|
+
const filePath = this.resolveExportFilePath(
|
|
51
|
+
folderPath,
|
|
52
|
+
baseFileName,
|
|
53
|
+
sessionId,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const frontmatter = this.generateFrontmatter(
|
|
58
|
+
agentLabel,
|
|
59
|
+
agentId,
|
|
60
|
+
sessionId,
|
|
61
|
+
effectiveTimestamp,
|
|
62
|
+
);
|
|
63
|
+
const chatContent = await this.convertMessagesToMarkdown(
|
|
64
|
+
messages,
|
|
65
|
+
agentLabel,
|
|
66
|
+
filePath,
|
|
67
|
+
);
|
|
68
|
+
const fullContent = `${frontmatter}\n\n${chatContent}`;
|
|
69
|
+
|
|
70
|
+
// Check if file already exists (path is already resolved for our session)
|
|
71
|
+
const existingFile =
|
|
72
|
+
this.plugin.app.vault.getAbstractFileByPath(filePath);
|
|
73
|
+
let file: TFile;
|
|
74
|
+
|
|
75
|
+
if (existingFile instanceof TFile) {
|
|
76
|
+
// File exists (same session), update it
|
|
77
|
+
await this.plugin.app.vault.modify(existingFile, fullContent);
|
|
78
|
+
file = existingFile;
|
|
79
|
+
} else {
|
|
80
|
+
// File doesn't exist, create it
|
|
81
|
+
file = await this.plugin.app.vault.create(
|
|
82
|
+
filePath,
|
|
83
|
+
fullContent,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Open the exported file if requested
|
|
88
|
+
if (openFile) {
|
|
89
|
+
const leaf = this.plugin.app.workspace.getLeaf(false);
|
|
90
|
+
await leaf.openFile(file);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.logger.log(`Chat exported to: ${filePath}`);
|
|
94
|
+
return filePath;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.logger.error("Export error:", error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async ensureFolderExists(folderPath: string): Promise<void> {
|
|
102
|
+
const folder = this.plugin.app.vault.getAbstractFileByPath(folderPath);
|
|
103
|
+
if (!folder) {
|
|
104
|
+
await this.plugin.app.vault.createFolder(folderPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract session_id from file's frontmatter cache.
|
|
110
|
+
*
|
|
111
|
+
* Uses Obsidian's metadataCache for efficient access (no disk I/O).
|
|
112
|
+
* This is the same pattern used in vault.adapter.ts and mention-service.ts.
|
|
113
|
+
*
|
|
114
|
+
* @param file - TFile to extract session_id from
|
|
115
|
+
* @returns session_id string if found, null otherwise
|
|
116
|
+
*/
|
|
117
|
+
private getSessionIdFromFile(file: TFile): string | null {
|
|
118
|
+
const cache = this.plugin.app.metadataCache.getFileCache(file);
|
|
119
|
+
const sessionId = cache?.frontmatter?.session_id as string | undefined;
|
|
120
|
+
return sessionId ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve export file path, handling session ID conflicts.
|
|
125
|
+
*
|
|
126
|
+
* Resolution logic:
|
|
127
|
+
* 1. If no file exists at base path → use base path
|
|
128
|
+
* 2. If file exists with same sessionId → use base path (overwrite)
|
|
129
|
+
* 3. If file exists with different sessionId → try suffixed paths (_2, _3, ...)
|
|
130
|
+
* - If suffixed file has same sessionId → use that path (overwrite)
|
|
131
|
+
* - If suffixed file has different sessionId → continue searching
|
|
132
|
+
* - If no suffixed file exists → use that path (create new)
|
|
133
|
+
*
|
|
134
|
+
* @param folderPath - Export folder path
|
|
135
|
+
* @param baseFileName - Base file name without extension
|
|
136
|
+
* @param sessionId - Current session's ID
|
|
137
|
+
* @returns Resolved file path
|
|
138
|
+
*/
|
|
139
|
+
private resolveExportFilePath(
|
|
140
|
+
folderPath: string,
|
|
141
|
+
baseFileName: string,
|
|
142
|
+
sessionId: string,
|
|
143
|
+
): string {
|
|
144
|
+
const basePath = `${folderPath}/${baseFileName}.md`;
|
|
145
|
+
|
|
146
|
+
// Check if base file exists
|
|
147
|
+
const existingFile =
|
|
148
|
+
this.plugin.app.vault.getAbstractFileByPath(basePath);
|
|
149
|
+
|
|
150
|
+
if (!(existingFile instanceof TFile)) {
|
|
151
|
+
// No existing file, use base path
|
|
152
|
+
return basePath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// File exists - check sessionId in frontmatter
|
|
156
|
+
const existingSessionId = this.getSessionIdFromFile(existingFile);
|
|
157
|
+
|
|
158
|
+
if (existingSessionId === sessionId) {
|
|
159
|
+
// Same session, overwrite
|
|
160
|
+
return basePath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Different session (or no sessionId in frontmatter) - find available suffix
|
|
164
|
+
// Start from 2 to follow common convention (file, file_2, file_3, ...)
|
|
165
|
+
for (let suffix = 2; suffix <= 100; suffix++) {
|
|
166
|
+
const suffixedPath = `${folderPath}/${baseFileName}_${suffix}.md`;
|
|
167
|
+
const suffixedFile =
|
|
168
|
+
this.plugin.app.vault.getAbstractFileByPath(suffixedPath);
|
|
169
|
+
|
|
170
|
+
if (!(suffixedFile instanceof TFile)) {
|
|
171
|
+
// No file at this path, use it
|
|
172
|
+
return suffixedPath;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check if this file belongs to our session
|
|
176
|
+
const suffixedSessionId = this.getSessionIdFromFile(suffixedFile);
|
|
177
|
+
if (suffixedSessionId === sessionId) {
|
|
178
|
+
// Found our session's file
|
|
179
|
+
return suffixedPath;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Different session, continue searching
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Safety fallback: exceeded limit, use the last checked path
|
|
186
|
+
// This should rarely happen (100+ exports with same timestamp)
|
|
187
|
+
this.logger.warn(
|
|
188
|
+
`Too many export files with same base name: ${baseFileName}`,
|
|
189
|
+
);
|
|
190
|
+
return `${folderPath}/${baseFileName}_101.md`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private generateFileName(timestamp: Date): string {
|
|
194
|
+
const settings = this.plugin.settings.exportSettings;
|
|
195
|
+
const template =
|
|
196
|
+
settings.filenameTemplate || "agent_client_{date}_{time}";
|
|
197
|
+
|
|
198
|
+
// Format date in local timezone: 20251115
|
|
199
|
+
const year = timestamp.getFullYear();
|
|
200
|
+
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
|
|
201
|
+
const day = String(timestamp.getDate()).padStart(2, "0");
|
|
202
|
+
const dateStr = `${year}${month}${day}`;
|
|
203
|
+
|
|
204
|
+
// Format time in local timezone: 012345
|
|
205
|
+
const hours = String(timestamp.getHours()).padStart(2, "0");
|
|
206
|
+
const minutes = String(timestamp.getMinutes()).padStart(2, "0");
|
|
207
|
+
const seconds = String(timestamp.getSeconds()).padStart(2, "0");
|
|
208
|
+
const timeStr = `${hours}${minutes}${seconds}`;
|
|
209
|
+
|
|
210
|
+
return template.replace("{date}", dateStr).replace("{time}", timeStr);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private generateFrontmatter(
|
|
214
|
+
agentLabel: string,
|
|
215
|
+
agentId: string,
|
|
216
|
+
sessionId: string,
|
|
217
|
+
timestamp: Date,
|
|
218
|
+
): string {
|
|
219
|
+
const settings = this.plugin.settings.exportSettings;
|
|
220
|
+
|
|
221
|
+
// Format timestamp in local timezone: YYYY-MM-DDTHH:mm:ss
|
|
222
|
+
const year = timestamp.getFullYear();
|
|
223
|
+
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
|
|
224
|
+
const day = String(timestamp.getDate()).padStart(2, "0");
|
|
225
|
+
const hours = String(timestamp.getHours()).padStart(2, "0");
|
|
226
|
+
const minutes = String(timestamp.getMinutes()).padStart(2, "0");
|
|
227
|
+
const seconds = String(timestamp.getSeconds()).padStart(2, "0");
|
|
228
|
+
const localTimestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
229
|
+
|
|
230
|
+
// Build tags line only if tag is specified
|
|
231
|
+
const tagsLine = settings.frontmatterTag.trim()
|
|
232
|
+
? `\ntags: [${settings.frontmatterTag.trim()}]`
|
|
233
|
+
: "";
|
|
234
|
+
|
|
235
|
+
return `---
|
|
236
|
+
created: ${localTimestamp}
|
|
237
|
+
agentDisplayName: ${agentLabel}
|
|
238
|
+
agentId: ${agentId}
|
|
239
|
+
session_id: ${sessionId}${tagsLine}
|
|
240
|
+
---`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async convertMessagesToMarkdown(
|
|
244
|
+
messages: ChatMessage[],
|
|
245
|
+
agentLabel: string,
|
|
246
|
+
exportFilePath: string,
|
|
247
|
+
): Promise<string> {
|
|
248
|
+
const settings = this.plugin.settings.exportSettings;
|
|
249
|
+
const context: ConvertContext = {
|
|
250
|
+
exportFilePath,
|
|
251
|
+
imageIndex: 0,
|
|
252
|
+
includeImages: settings.includeImages,
|
|
253
|
+
imageLocation: settings.imageLocation,
|
|
254
|
+
imageCustomFolder: settings.imageCustomFolder,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
let markdown = `# ${agentLabel}\n\n`;
|
|
258
|
+
|
|
259
|
+
for (const message of messages) {
|
|
260
|
+
const timeStr = message.timestamp.toLocaleTimeString();
|
|
261
|
+
const role = message.role === "user" ? "User" : "Assistant";
|
|
262
|
+
|
|
263
|
+
markdown += `## ${timeStr} - ${role}\n\n`;
|
|
264
|
+
|
|
265
|
+
for (const content of message.content) {
|
|
266
|
+
markdown += await this.convertContentToMarkdown(
|
|
267
|
+
content,
|
|
268
|
+
context,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
markdown += "\n---\n\n";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return markdown;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async convertContentToMarkdown(
|
|
279
|
+
content: MessageContent,
|
|
280
|
+
context: ConvertContext,
|
|
281
|
+
): Promise<string> {
|
|
282
|
+
switch (content.type) {
|
|
283
|
+
case "text":
|
|
284
|
+
return content.text + "\n\n";
|
|
285
|
+
|
|
286
|
+
case "text_with_context": {
|
|
287
|
+
// User messages with auto-mention context
|
|
288
|
+
// Add auto-mention in @[[note]] format at the beginning
|
|
289
|
+
let exportText = "";
|
|
290
|
+
if (content.autoMentionContext) {
|
|
291
|
+
const { noteName, selection } = content.autoMentionContext;
|
|
292
|
+
if (selection) {
|
|
293
|
+
exportText += `@[[${noteName}]]:${selection.fromLine}-${selection.toLine}\n`;
|
|
294
|
+
} else {
|
|
295
|
+
exportText += `@[[${noteName}]]\n`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Add the message text (which may contain additional @[[note]] mentions)
|
|
299
|
+
exportText += content.text + "\n\n";
|
|
300
|
+
return exportText;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "agent_thought":
|
|
304
|
+
return `> [!info]- Thinking\n> ${content.text.split("\n").join("\n> ")}\n\n`;
|
|
305
|
+
|
|
306
|
+
case "tool_call":
|
|
307
|
+
return this.convertToolCallToMarkdown(content);
|
|
308
|
+
|
|
309
|
+
case "terminal":
|
|
310
|
+
return `### 🖥️ Terminal: ${content.terminalId.slice(0, 8)}\n\n`;
|
|
311
|
+
|
|
312
|
+
case "plan":
|
|
313
|
+
return this.convertPlanToMarkdown(content);
|
|
314
|
+
|
|
315
|
+
case "permission_request":
|
|
316
|
+
return this.convertPermissionRequestToMarkdown(content);
|
|
317
|
+
|
|
318
|
+
case "resource_link":
|
|
319
|
+
return `[${content.name}](${content.uri})\n\n`;
|
|
320
|
+
|
|
321
|
+
case "image":
|
|
322
|
+
// Skip if images are not included
|
|
323
|
+
if (!context.includeImages) {
|
|
324
|
+
return "";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// External URI - use as-is
|
|
328
|
+
if (content.uri) {
|
|
329
|
+
return `\n\n`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Base64 embedding mode
|
|
333
|
+
if (context.imageLocation === "base64") {
|
|
334
|
+
return `\n\n`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Save as attachment (obsidian or custom)
|
|
338
|
+
try {
|
|
339
|
+
context.imageIndex++;
|
|
340
|
+
const attachmentPath = await this.saveImageAsAttachment(
|
|
341
|
+
content.data,
|
|
342
|
+
content.mimeType,
|
|
343
|
+
context.exportFilePath,
|
|
344
|
+
context.imageIndex,
|
|
345
|
+
context.imageLocation,
|
|
346
|
+
context.imageCustomFolder,
|
|
347
|
+
);
|
|
348
|
+
// Use filename only (Obsidian resolves it)
|
|
349
|
+
const fileName = attachmentPath.split("/").pop();
|
|
350
|
+
return `![[${fileName}]]\n\n`;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
this.logger.error(
|
|
353
|
+
`Failed to save image as attachment: ${error}`,
|
|
354
|
+
);
|
|
355
|
+
// Fallback to base64 embedding
|
|
356
|
+
return `\n\n`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
default:
|
|
360
|
+
return "";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private convertToolCallToMarkdown(
|
|
365
|
+
content: Extract<MessageContent, { type: "tool_call" }>,
|
|
366
|
+
): string {
|
|
367
|
+
let md = `### 🔧 ${content.title || "Tool"}\n\n`;
|
|
368
|
+
|
|
369
|
+
// Add locations if present
|
|
370
|
+
if (content.locations && content.locations.length > 0) {
|
|
371
|
+
const locationStrs = content.locations.map((loc) =>
|
|
372
|
+
loc.line != null
|
|
373
|
+
? `\`${loc.path}:${loc.line}\``
|
|
374
|
+
: `\`${loc.path}\``,
|
|
375
|
+
);
|
|
376
|
+
md += `**Locations**: ${locationStrs.join(", ")}\n\n`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
md += `**Status**: ${content.status}\n\n`;
|
|
380
|
+
|
|
381
|
+
// Only export diffs
|
|
382
|
+
if (content.content && content.content.length > 0) {
|
|
383
|
+
for (const item of content.content) {
|
|
384
|
+
if (item.type === "diff") {
|
|
385
|
+
md += this.convertDiffToMarkdown(item);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return md;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private convertDiffToMarkdown(diff: {
|
|
394
|
+
type: "diff";
|
|
395
|
+
path: string;
|
|
396
|
+
oldText?: string | null;
|
|
397
|
+
newText: string;
|
|
398
|
+
}): string {
|
|
399
|
+
let md = `**File**: \`${diff.path}\`\n\n`;
|
|
400
|
+
|
|
401
|
+
// Check if this is a new file
|
|
402
|
+
if (
|
|
403
|
+
diff.oldText === null ||
|
|
404
|
+
diff.oldText === undefined ||
|
|
405
|
+
diff.oldText === ""
|
|
406
|
+
) {
|
|
407
|
+
md += "```diff\n";
|
|
408
|
+
diff.newText.split("\n").forEach((line) => {
|
|
409
|
+
md += `+ ${line}\n`;
|
|
410
|
+
});
|
|
411
|
+
md += "```\n\n";
|
|
412
|
+
return md;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Generate proper diff format
|
|
416
|
+
const oldLines = diff.oldText.split("\n");
|
|
417
|
+
const newLines = diff.newText.split("\n");
|
|
418
|
+
|
|
419
|
+
md += "```diff\n";
|
|
420
|
+
|
|
421
|
+
// Show removed lines
|
|
422
|
+
oldLines.forEach((line) => {
|
|
423
|
+
md += `- ${line}\n`;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Show added lines
|
|
427
|
+
newLines.forEach((line) => {
|
|
428
|
+
md += `+ ${line}\n`;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
md += "```\n\n";
|
|
432
|
+
return md;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private convertPlanToMarkdown(
|
|
436
|
+
content: Extract<MessageContent, { type: "plan" }>,
|
|
437
|
+
): string {
|
|
438
|
+
let md = `> [!plan] Plan\n`;
|
|
439
|
+
for (const entry of content.entries) {
|
|
440
|
+
const status =
|
|
441
|
+
entry.status === "completed"
|
|
442
|
+
? "✅"
|
|
443
|
+
: entry.status === "in_progress"
|
|
444
|
+
? "🔄"
|
|
445
|
+
: "⏳";
|
|
446
|
+
md += `> ${status} ${entry.content}\n`;
|
|
447
|
+
}
|
|
448
|
+
md += `\n`;
|
|
449
|
+
return md;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private convertPermissionRequestToMarkdown(
|
|
453
|
+
content: Extract<MessageContent, { type: "permission_request" }>,
|
|
454
|
+
): string {
|
|
455
|
+
const status = content.isCancelled ? "Cancelled" : "Requested";
|
|
456
|
+
return `### ⚠️ Permission: ${content.toolCall.title || "Unknown"} (${status})\n\n`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Save a base64-encoded image as an attachment file.
|
|
461
|
+
* Uses Obsidian's attachment settings to determine the save location.
|
|
462
|
+
* Skips saving if the file already exists.
|
|
463
|
+
*/
|
|
464
|
+
private async saveImageAsAttachment(
|
|
465
|
+
base64Data: string,
|
|
466
|
+
mimeType: string,
|
|
467
|
+
exportFilePath: string,
|
|
468
|
+
imageIndex: number,
|
|
469
|
+
imageLocation: "obsidian" | "custom",
|
|
470
|
+
imageCustomFolder: string,
|
|
471
|
+
): Promise<string> {
|
|
472
|
+
const ext = this.getExtensionFromMimeType(mimeType);
|
|
473
|
+
|
|
474
|
+
// Generate image filename based on export filename
|
|
475
|
+
const exportFileName = exportFilePath.replace(/\.md$/, "");
|
|
476
|
+
const baseName = exportFileName.split("/").pop() || "image";
|
|
477
|
+
const imageFileName = `${baseName}_${String(imageIndex).padStart(3, "0")}.${ext}`;
|
|
478
|
+
|
|
479
|
+
let attachmentPath: string;
|
|
480
|
+
|
|
481
|
+
if (imageLocation === "custom") {
|
|
482
|
+
// Save to custom folder
|
|
483
|
+
const folder = imageCustomFolder || "Agent Client";
|
|
484
|
+
await this.ensureFolderExists(folder);
|
|
485
|
+
attachmentPath = `${folder}/${imageFileName}`;
|
|
486
|
+
|
|
487
|
+
// Check if file already exists
|
|
488
|
+
const existingFile =
|
|
489
|
+
this.plugin.app.vault.getAbstractFileByPath(attachmentPath);
|
|
490
|
+
if (existingFile instanceof TFile) {
|
|
491
|
+
this.logger.log(
|
|
492
|
+
`Image already exists, skipping: ${attachmentPath}`,
|
|
493
|
+
);
|
|
494
|
+
return attachmentPath;
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
// Use Obsidian's attachment folder settings
|
|
498
|
+
attachmentPath =
|
|
499
|
+
await this.plugin.app.fileManager.getAvailablePathForAttachment(
|
|
500
|
+
imageFileName,
|
|
501
|
+
exportFilePath,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Check if file already exists by comparing paths
|
|
505
|
+
// getAvailablePathForAttachment returns the original name if it doesn't exist,
|
|
506
|
+
// or adds a suffix (e.g., "image_001 1.png") if it does exist.
|
|
507
|
+
if (!attachmentPath.endsWith(imageFileName)) {
|
|
508
|
+
// File exists - return the original path (without suffix)
|
|
509
|
+
const originalPath = attachmentPath.replace(
|
|
510
|
+
/ \d+(\.[^.]+)$/,
|
|
511
|
+
"$1",
|
|
512
|
+
);
|
|
513
|
+
this.logger.log(
|
|
514
|
+
`Image already exists, skipping: ${originalPath}`,
|
|
515
|
+
);
|
|
516
|
+
return originalPath;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Save the image
|
|
521
|
+
const binaryData = this.base64ToArrayBuffer(base64Data);
|
|
522
|
+
await this.plugin.app.vault.createBinary(attachmentPath, binaryData);
|
|
523
|
+
this.logger.log(`Image saved as attachment: ${attachmentPath}`);
|
|
524
|
+
|
|
525
|
+
return attachmentPath;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Get file extension from MIME type.
|
|
530
|
+
*/
|
|
531
|
+
private getExtensionFromMimeType(mimeType: string): string {
|
|
532
|
+
const map: Record<string, string> = {
|
|
533
|
+
"image/png": "png",
|
|
534
|
+
"image/jpeg": "jpg",
|
|
535
|
+
"image/gif": "gif",
|
|
536
|
+
"image/webp": "webp",
|
|
537
|
+
};
|
|
538
|
+
return map[mimeType] || "png";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Convert base64 string to ArrayBuffer.
|
|
543
|
+
*/
|
|
544
|
+
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
|
545
|
+
const binaryString = atob(base64);
|
|
546
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
547
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
548
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
549
|
+
}
|
|
550
|
+
return bytes.buffer;
|
|
551
|
+
}
|
|
552
|
+
}
|