jp.keijiro.ai.assistant.extensions 1.1.3 → 1.2.1
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/.attestation.p7m +0 -0
- package/CHANGELOG.md +14 -0
- package/Editor/ConversationExtractorWindow.cs +483 -130
- package/Editor/ConversationExtractorWindow.uss +80 -0
- package/Editor/ConversationExtractorWindow.uss.meta +12 -0
- package/Editor/ConversationExtractorWindow.uxml +23 -0
- package/Editor/ConversationExtractorWindow.uxml.meta +10 -0
- package/Editor/ConversationRow.uxml +6 -0
- package/Editor/ConversationRow.uxml.meta +10 -0
- package/package.json +4 -4
package/.attestation.p7m
CHANGED
|
Binary file
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.1] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Prefixed saved conversation filenames with the conversation's date and time so exported files sort chronologically.
|
|
13
|
+
|
|
14
|
+
## [1.2.0] - 2026-06-05
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Reworked the Conversation Extractor to read conversations through the AI Assistant in-process API instead of parsing the relay log.
|
|
19
|
+
- Rebuilt the Conversation Extractor window with UI Toolkit, adding a resizable conversation list, per-conversation selection, a scrollable read-only transcript, and a toggle for including tool calls.
|
|
20
|
+
- Updated project and package metadata for AI Assistant 2.11.0-pre.1.
|
|
21
|
+
|
|
8
22
|
## [1.1.3] - 2026-05-15
|
|
9
23
|
|
|
10
24
|
### Fixed
|
|
@@ -1,227 +1,580 @@
|
|
|
1
1
|
using System;
|
|
2
|
+
using System.Collections;
|
|
2
3
|
using System.Collections.Generic;
|
|
3
4
|
using System.IO;
|
|
5
|
+
using System.Linq;
|
|
6
|
+
using System.Reflection;
|
|
4
7
|
using System.Text;
|
|
5
|
-
using System.
|
|
8
|
+
using System.Threading;
|
|
9
|
+
using System.Threading.Tasks;
|
|
6
10
|
using UnityEditor;
|
|
11
|
+
using UnityEditor.UIElements;
|
|
7
12
|
using UnityEngine;
|
|
13
|
+
using UnityEngine.UIElements;
|
|
8
14
|
|
|
9
15
|
namespace AIAssistantExtensions {
|
|
10
16
|
|
|
17
|
+
// Lists the (Unity-hosted) conversations from the AI Assistant package's in-process
|
|
18
|
+
// API and extracts whichever one you pick. Everything in that API is internal and
|
|
19
|
+
// our assembly is not on its InternalsVisibleTo allowlist, so we reach it through
|
|
20
|
+
// reflection. The live IAssistantProvider only exists while an AI Assistant window
|
|
21
|
+
// is open, so that window must be open for this to work.
|
|
11
22
|
class ConversationExtractorWindow : EditorWindow
|
|
12
23
|
{
|
|
13
|
-
const string
|
|
24
|
+
const string AssetDir = "Packages/jp.keijiro.ai.assistant.extensions/Editor/";
|
|
25
|
+
const string UxmlPath = AssetDir + "ConversationExtractorWindow.uxml";
|
|
26
|
+
const string RowUxmlPath = AssetDir + "ConversationRow.uxml";
|
|
27
|
+
const string UssPath = AssetDir + "ConversationExtractorWindow.uss";
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
RegexOptions.Compiled);
|
|
29
|
+
const string AssistantWindowTypeName = "Unity.AI.Assistant.UI.Editor.Scripts.AssistantWindow";
|
|
30
|
+
const int EventTimeoutMs = 20000;
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
const BindingFlags InstanceMembers =
|
|
33
|
+
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
34
|
+
|
|
35
|
+
struct ConversationItem
|
|
36
|
+
{
|
|
37
|
+
public object IdBoxed; // boxed AssistantConversationId, passed straight back to the API
|
|
38
|
+
public string Id; // its string value, used to match the load callback
|
|
39
|
+
public string Title;
|
|
40
|
+
public long Timestamp;
|
|
41
|
+
public bool Favorite;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
readonly List<ConversationItem> _conversations = new();
|
|
45
|
+
|
|
46
|
+
object _loadedConversation; // last loaded conversation, for rebuilding without re-fetching
|
|
20
47
|
string _markdown;
|
|
21
|
-
string _status;
|
|
22
|
-
|
|
48
|
+
string _status; // right pane: extraction lifecycle only
|
|
49
|
+
string _listStatus; // left pane: empty-state message
|
|
50
|
+
bool _busy;
|
|
51
|
+
bool _extracting;
|
|
52
|
+
bool _includeToolCalls = true;
|
|
53
|
+
|
|
54
|
+
// UI elements (resolved in CreateGUI).
|
|
55
|
+
VisualTreeAsset _rowTemplate;
|
|
56
|
+
ToolbarButton _refreshButton;
|
|
57
|
+
Toggle _toolCallsToggle;
|
|
58
|
+
ToolbarButton _copyButton;
|
|
59
|
+
ToolbarButton _saveButton;
|
|
60
|
+
Label _listHeader;
|
|
61
|
+
Label _emptyLabel;
|
|
62
|
+
ListView _listView;
|
|
63
|
+
TextField _preview;
|
|
64
|
+
|
|
65
|
+
// Event plumbing shared with the reflection callbacks.
|
|
66
|
+
Delegate _refreshedHandler;
|
|
67
|
+
Delegate _loadedHandler;
|
|
68
|
+
TaskCompletionSource<object> _refreshTcs;
|
|
69
|
+
TaskCompletionSource<object> _loadTcs;
|
|
70
|
+
string _pendingLoadId;
|
|
23
71
|
|
|
24
72
|
[MenuItem("Window/AI/Conversation Extractor")]
|
|
25
73
|
static void Open()
|
|
26
74
|
{
|
|
27
75
|
var window = GetWindow<ConversationExtractorWindow>();
|
|
28
76
|
window.titleContent = new GUIContent("Conversation Extractor");
|
|
29
|
-
window.minSize = new Vector2(
|
|
30
|
-
window.Extract();
|
|
77
|
+
window.minSize = new Vector2(640, 360);
|
|
31
78
|
}
|
|
32
79
|
|
|
33
|
-
|
|
80
|
+
// --- UI construction ----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
void CreateGUI()
|
|
34
83
|
{
|
|
35
|
-
|
|
84
|
+
var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
|
85
|
+
if (tree == null)
|
|
36
86
|
{
|
|
37
|
-
|
|
38
|
-
|
|
87
|
+
rootVisualElement.Add(new Label($"Layout asset not found: {UxmlPath}"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
39
90
|
|
|
40
|
-
|
|
91
|
+
tree.CloneTree(rootVisualElement);
|
|
41
92
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
93
|
+
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
|
94
|
+
if (styleSheet != null)
|
|
95
|
+
rootVisualElement.styleSheets.Add(styleSheet);
|
|
96
|
+
|
|
97
|
+
_rowTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(RowUxmlPath);
|
|
98
|
+
|
|
99
|
+
_refreshButton = rootVisualElement.Q<ToolbarButton>("refresh-button");
|
|
100
|
+
_toolCallsToggle = rootVisualElement.Q<Toggle>("toolcalls-toggle");
|
|
101
|
+
_copyButton = rootVisualElement.Q<ToolbarButton>("copy-button");
|
|
102
|
+
_saveButton = rootVisualElement.Q<ToolbarButton>("save-button");
|
|
103
|
+
_listHeader = rootVisualElement.Q<Label>("list-header");
|
|
104
|
+
_emptyLabel = rootVisualElement.Q<Label>("empty-label");
|
|
105
|
+
_listView = rootVisualElement.Q<ListView>("conversation-list");
|
|
106
|
+
_preview = rootVisualElement.Q<TextField>("preview");
|
|
107
|
+
|
|
108
|
+
_refreshButton.clicked += () => { if (!_busy) _ = RefreshList(); };
|
|
109
|
+
_copyButton.clicked += CopyToClipboard;
|
|
110
|
+
_saveButton.clicked += SaveMarkdown;
|
|
49
111
|
|
|
50
|
-
|
|
51
|
-
|
|
112
|
+
_toolCallsToggle.SetValueWithoutNotify(_includeToolCalls);
|
|
113
|
+
_toolCallsToggle.RegisterValueChangedCallback(evt => OnToolCallsChanged(evt.newValue));
|
|
52
114
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
115
|
+
_listView.itemsSource = _conversations;
|
|
116
|
+
_listView.makeItem = MakeRow;
|
|
117
|
+
_listView.bindItem = BindRow;
|
|
118
|
+
_listView.selectionChanged += OnSelectionChanged;
|
|
119
|
+
|
|
120
|
+
// Show the initial hint; RefreshList drives the toolbar and list state.
|
|
121
|
+
UpdatePreview();
|
|
122
|
+
_ = RefreshList();
|
|
56
123
|
}
|
|
57
124
|
|
|
58
|
-
|
|
125
|
+
VisualElement MakeRow()
|
|
126
|
+
{
|
|
127
|
+
if (_rowTemplate != null)
|
|
128
|
+
return _rowTemplate.Instantiate();
|
|
129
|
+
|
|
130
|
+
var container = new VisualElement();
|
|
131
|
+
container.Add(new Label { name = "row-title" });
|
|
132
|
+
container.Add(new Label { name = "row-date" });
|
|
133
|
+
return container;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
void BindRow(VisualElement element, int index)
|
|
137
|
+
{
|
|
138
|
+
var item = _conversations[index];
|
|
139
|
+
|
|
140
|
+
var title = element.Q<Label>("row-title");
|
|
141
|
+
// Titles can contain embedded newlines; collapse them so the row stays
|
|
142
|
+
// single-line (white-space:nowrap doesn't strip explicit line breaks).
|
|
143
|
+
title.text = (item.Favorite ? "★ " : "") + SingleLine(DisplayTitle(item.Title));
|
|
144
|
+
|
|
145
|
+
var date = element.Q<Label>("row-date");
|
|
146
|
+
var formatted = FormatDate(item.Timestamp);
|
|
147
|
+
date.text = formatted;
|
|
148
|
+
date.style.display = string.IsNullOrEmpty(formatted) ? DisplayStyle.None : DisplayStyle.Flex;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
void OnSelectionChanged(IEnumerable<object> selection)
|
|
152
|
+
{
|
|
153
|
+
if (_busy) return;
|
|
154
|
+
|
|
155
|
+
var index = _listView.selectedIndex;
|
|
156
|
+
if (index >= 0 && index < _conversations.Count)
|
|
157
|
+
_ = ExtractConversation(_conversations[index]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
void OnToolCallsChanged(bool include)
|
|
161
|
+
{
|
|
162
|
+
_includeToolCalls = include;
|
|
163
|
+
|
|
164
|
+
// Rebuild from the already-loaded conversation; no need to re-fetch.
|
|
165
|
+
if (_busy || _loadedConversation == null) return;
|
|
166
|
+
_markdown = BuildConversationMarkdown(_loadedConversation, _includeToolCalls);
|
|
167
|
+
UpdateToolbar();
|
|
168
|
+
UpdatePreview();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- State -> UI --------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
void UpdateToolbar()
|
|
59
174
|
{
|
|
60
|
-
|
|
61
|
-
|
|
175
|
+
_refreshButton?.SetEnabled(!_busy);
|
|
176
|
+
|
|
177
|
+
var hasMarkdown = !_busy && !string.IsNullOrEmpty(_markdown);
|
|
178
|
+
_copyButton?.SetEnabled(hasMarkdown);
|
|
179
|
+
_saveButton?.SetEnabled(hasMarkdown);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
void UpdateList()
|
|
183
|
+
{
|
|
184
|
+
if (_listView == null) return;
|
|
185
|
+
|
|
186
|
+
_listHeader.text = $"Conversations ({_conversations.Count})";
|
|
187
|
+
_listView.Rebuild();
|
|
188
|
+
|
|
189
|
+
var empty = _conversations.Count == 0;
|
|
190
|
+
_listView.style.display = empty ? DisplayStyle.None : DisplayStyle.Flex;
|
|
191
|
+
_emptyLabel.style.display = empty ? DisplayStyle.Flex : DisplayStyle.None;
|
|
192
|
+
if (empty)
|
|
193
|
+
_emptyLabel.text = _busy
|
|
194
|
+
? "Loading..."
|
|
195
|
+
: (string.IsNullOrEmpty(_listStatus) ? "No conversations." : _listStatus);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
void UpdatePreview()
|
|
199
|
+
{
|
|
200
|
+
if (_preview == null) return;
|
|
201
|
+
|
|
202
|
+
// While extracting show the status text; otherwise the extracted markdown,
|
|
203
|
+
// or a hint when there is none.
|
|
204
|
+
string content;
|
|
205
|
+
if (_extracting)
|
|
206
|
+
content = _status;
|
|
207
|
+
else if (!string.IsNullOrEmpty(_markdown))
|
|
208
|
+
content = _markdown;
|
|
209
|
+
else
|
|
210
|
+
content = string.IsNullOrEmpty(_status) ? "Select a conversation to extract." : _status;
|
|
211
|
+
|
|
212
|
+
_preview.SetValueWithoutNotify(content ?? "");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Operations ---------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
async Task RefreshList()
|
|
218
|
+
{
|
|
219
|
+
object provider = null;
|
|
220
|
+
_busy = true;
|
|
221
|
+
_listStatus = null;
|
|
222
|
+
UpdateToolbar();
|
|
223
|
+
UpdateList();
|
|
224
|
+
|
|
225
|
+
try
|
|
62
226
|
{
|
|
227
|
+
provider = GetLiveProvider(out var error);
|
|
228
|
+
if (provider == null) { _listStatus = error; return; }
|
|
229
|
+
|
|
230
|
+
Subscribe(provider);
|
|
231
|
+
|
|
232
|
+
_conversations.Clear();
|
|
233
|
+
_conversations.AddRange((await RefreshConversations(provider))
|
|
234
|
+
.OrderByDescending(c => c.Timestamp));
|
|
235
|
+
_loadedConversation = null;
|
|
63
236
|
_markdown = "";
|
|
64
|
-
_status =
|
|
65
|
-
|
|
237
|
+
_status = null;
|
|
238
|
+
_listView?.SetSelectionWithoutNotify(Array.Empty<int>());
|
|
66
239
|
}
|
|
240
|
+
catch (Exception e)
|
|
241
|
+
{
|
|
242
|
+
_listStatus = $"Failed to refresh: {e.Message}";
|
|
243
|
+
Debug.LogException(e);
|
|
244
|
+
}
|
|
245
|
+
finally
|
|
246
|
+
{
|
|
247
|
+
Unsubscribe(provider);
|
|
248
|
+
_busy = false;
|
|
249
|
+
UpdateToolbar();
|
|
250
|
+
UpdateList();
|
|
251
|
+
UpdatePreview();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async Task ExtractConversation(ConversationItem item)
|
|
256
|
+
{
|
|
257
|
+
object provider = null;
|
|
258
|
+
_busy = true;
|
|
259
|
+
_extracting = true;
|
|
260
|
+
_status = $"Extracting \"{DisplayTitle(item.Title)}\"...";
|
|
261
|
+
UpdateToolbar();
|
|
262
|
+
UpdatePreview();
|
|
263
|
+
|
|
264
|
+
try
|
|
265
|
+
{
|
|
266
|
+
provider = GetLiveProvider(out var error);
|
|
267
|
+
if (provider == null) { _markdown = ""; _status = error; return; }
|
|
67
268
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
269
|
+
Subscribe(provider);
|
|
270
|
+
|
|
271
|
+
var conversation = await LoadConversation(provider, item);
|
|
272
|
+
_loadedConversation = conversation;
|
|
273
|
+
_markdown = BuildConversationMarkdown(conversation, _includeToolCalls);
|
|
274
|
+
_status = null;
|
|
275
|
+
}
|
|
276
|
+
catch (Exception e)
|
|
277
|
+
{
|
|
278
|
+
_loadedConversation = null;
|
|
279
|
+
_markdown = "";
|
|
280
|
+
_status = $"Extraction failed: {e.Message}";
|
|
281
|
+
Debug.LogException(e);
|
|
282
|
+
}
|
|
283
|
+
finally
|
|
284
|
+
{
|
|
285
|
+
Unsubscribe(provider);
|
|
286
|
+
_busy = false;
|
|
287
|
+
_extracting = false;
|
|
288
|
+
UpdateToolbar();
|
|
289
|
+
UpdatePreview();
|
|
290
|
+
}
|
|
71
291
|
}
|
|
72
292
|
|
|
73
293
|
void CopyToClipboard()
|
|
74
294
|
{
|
|
75
295
|
EditorGUIUtility.systemCopyBuffer = _markdown ?? "";
|
|
76
|
-
|
|
296
|
+
ShowNotification(new GUIContent("Copied to clipboard"));
|
|
77
297
|
}
|
|
78
298
|
|
|
79
299
|
void SaveMarkdown()
|
|
80
300
|
{
|
|
81
|
-
var projectRoot =
|
|
82
|
-
var
|
|
83
|
-
var
|
|
301
|
+
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory();
|
|
302
|
+
var index = _listView?.selectedIndex ?? -1;
|
|
303
|
+
var hasSelection = index >= 0 && index < _conversations.Count;
|
|
304
|
+
var name = hasSelection ? SanitizeFileName(_conversations[index].Title) : "conversation";
|
|
305
|
+
var stamp = hasSelection ? FormatFileTimestamp(_conversations[index].Timestamp) : "";
|
|
306
|
+
var fileName = string.IsNullOrEmpty(stamp) ? name : $"{stamp} {name}";
|
|
307
|
+
var defaultPath = Path.Combine(projectRoot, "Logs", $"{fileName}.md");
|
|
308
|
+
var path = EditorUtility.SaveFilePanel("Save extracted conversation", Path.GetDirectoryName(defaultPath), Path.GetFileName(defaultPath), "md");
|
|
84
309
|
if (string.IsNullOrEmpty(path)) return;
|
|
85
310
|
|
|
86
311
|
File.WriteAllText(path, _markdown ?? "", Encoding.UTF8);
|
|
87
|
-
|
|
312
|
+
ShowNotification(new GUIContent($"Saved to {Path.GetFileName(path)}"));
|
|
88
313
|
}
|
|
89
314
|
|
|
90
|
-
|
|
315
|
+
// --- Live provider acquisition -----------------------------------------
|
|
316
|
+
|
|
317
|
+
static object GetLiveProvider(out string error)
|
|
91
318
|
{
|
|
92
|
-
|
|
93
|
-
ConversationPair currentPair = null;
|
|
94
|
-
string activeResponseId = null;
|
|
319
|
+
error = null;
|
|
95
320
|
|
|
96
|
-
var
|
|
97
|
-
|
|
321
|
+
var windowType = FindType(AssistantWindowTypeName);
|
|
322
|
+
if (windowType == null)
|
|
98
323
|
{
|
|
99
|
-
|
|
100
|
-
|
|
324
|
+
error = "AI Assistant package not found. Is it installed?";
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
101
327
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
328
|
+
var window = windowType
|
|
329
|
+
.GetMethod("FindExistingWindow", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
|
|
330
|
+
?.Invoke(null, null);
|
|
331
|
+
if (window == null)
|
|
332
|
+
{
|
|
333
|
+
error = "Open the AI Assistant window first (Window > AI > Assistant), then retry.";
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
106
336
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
337
|
+
var provider = windowType.GetProperty("AssistantInstance", InstanceMembers)?.GetValue(window);
|
|
338
|
+
if (provider == null)
|
|
339
|
+
error = "AI Assistant instance is not ready yet. Interact with the Assistant window, then retry.";
|
|
111
340
|
|
|
112
|
-
|
|
113
|
-
|
|
341
|
+
return provider;
|
|
342
|
+
}
|
|
114
343
|
|
|
115
|
-
|
|
116
|
-
{
|
|
117
|
-
activeResponseId = parsed.MessageId;
|
|
118
|
-
currentPair.ResponseMessageId = activeResponseId;
|
|
119
|
-
}
|
|
344
|
+
// --- Event-driven calls -------------------------------------------------
|
|
120
345
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
currentPair.ResponseLines.Add(i + 1);
|
|
125
|
-
}
|
|
346
|
+
async Task<List<ConversationItem>> RefreshConversations(object provider)
|
|
347
|
+
{
|
|
348
|
+
_refreshTcs = new TaskCompletionSource<object>();
|
|
126
349
|
|
|
127
|
-
|
|
350
|
+
var method = provider.GetType().GetMethod(
|
|
351
|
+
"RefreshConversationsAsync", InstanceMembers,
|
|
352
|
+
null, new[] { typeof(CancellationToken), typeof(bool) }, null);
|
|
353
|
+
if (method == null)
|
|
354
|
+
throw new MissingMethodException("IAssistantProvider.RefreshConversationsAsync not found.");
|
|
355
|
+
|
|
356
|
+
await (Task)method.Invoke(provider, new object[] { CancellationToken.None, false });
|
|
357
|
+
var infos = await WithTimeout(_refreshTcs.Task);
|
|
358
|
+
|
|
359
|
+
// The event delivers IEnumerable<AssistantConversationInfo> (a boxed struct per item).
|
|
360
|
+
var result = new List<ConversationItem>();
|
|
361
|
+
foreach (var info in (IEnumerable)infos)
|
|
362
|
+
{
|
|
363
|
+
var idBoxed = GetMember(info, "Id");
|
|
364
|
+
result.Add(new ConversationItem
|
|
128
365
|
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
366
|
+
IdBoxed = idBoxed,
|
|
367
|
+
Id = GetMember(idBoxed, "Value") as string,
|
|
368
|
+
Title = GetMember(info, "Title") as string,
|
|
369
|
+
Timestamp = Convert.ToInt64(GetMember(info, "LastMessageTimestamp") ?? 0L),
|
|
370
|
+
Favorite = GetMember(info, "IsFavorite") is true
|
|
371
|
+
});
|
|
133
372
|
}
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async Task<object> LoadConversation(object provider, ConversationItem item)
|
|
377
|
+
{
|
|
378
|
+
_pendingLoadId = item.Id;
|
|
379
|
+
_loadTcs = new TaskCompletionSource<object>();
|
|
134
380
|
|
|
135
|
-
|
|
136
|
-
|
|
381
|
+
var method = provider.GetType().GetMethod("ConversationLoad", InstanceMembers);
|
|
382
|
+
if (method == null)
|
|
383
|
+
throw new MissingMethodException("IAssistantProvider.ConversationLoad not found.");
|
|
137
384
|
|
|
138
|
-
|
|
385
|
+
await (Task)method.Invoke(provider, new[] { item.IdBoxed, CancellationToken.None });
|
|
386
|
+
return await WithTimeout(_loadTcs.Task);
|
|
139
387
|
}
|
|
140
388
|
|
|
141
|
-
static
|
|
389
|
+
static async Task<object> WithTimeout(Task<object> task)
|
|
142
390
|
{
|
|
143
|
-
|
|
144
|
-
|
|
391
|
+
if (await Task.WhenAny(task, Task.Delay(EventTimeoutMs)) != task)
|
|
392
|
+
throw new TimeoutException("Timed out waiting for the AI Assistant API to respond.");
|
|
393
|
+
return await task;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
void Subscribe(object provider)
|
|
397
|
+
{
|
|
398
|
+
var type = provider.GetType();
|
|
399
|
+
|
|
400
|
+
// Bind handlers taking `object` to Action<T> events via delegate contravariance.
|
|
401
|
+
var refreshedEvent = type.GetEvent("ConversationsRefreshed", InstanceMembers);
|
|
402
|
+
_refreshedHandler = Delegate.CreateDelegate(
|
|
403
|
+
refreshedEvent.EventHandlerType, this,
|
|
404
|
+
GetType().GetMethod(nameof(OnConversationsRefreshed), InstanceMembers));
|
|
405
|
+
refreshedEvent.AddEventHandler(provider, _refreshedHandler);
|
|
406
|
+
|
|
407
|
+
var loadedEvent = type.GetEvent("ConversationLoaded", InstanceMembers);
|
|
408
|
+
_loadedHandler = Delegate.CreateDelegate(
|
|
409
|
+
loadedEvent.EventHandlerType, this,
|
|
410
|
+
GetType().GetMethod(nameof(OnConversationLoaded), InstanceMembers));
|
|
411
|
+
loadedEvent.AddEventHandler(provider, _loadedHandler);
|
|
412
|
+
}
|
|
145
413
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
414
|
+
void Unsubscribe(object provider)
|
|
415
|
+
{
|
|
416
|
+
if (provider == null) return;
|
|
417
|
+
|
|
418
|
+
var type = provider.GetType();
|
|
419
|
+
if (_refreshedHandler != null)
|
|
420
|
+
type.GetEvent("ConversationsRefreshed", InstanceMembers)?.RemoveEventHandler(provider, _refreshedHandler);
|
|
421
|
+
if (_loadedHandler != null)
|
|
422
|
+
type.GetEvent("ConversationLoaded", InstanceMembers)?.RemoveEventHandler(provider, _loadedHandler);
|
|
423
|
+
|
|
424
|
+
_refreshedHandler = _loadedHandler = null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Reflection callbacks. May arrive on a background thread; TaskCompletionSource
|
|
428
|
+
// marshals the awaiting continuation back to the captured (main) context.
|
|
429
|
+
void OnConversationsRefreshed(object infos) => _refreshTcs?.TrySetResult(infos);
|
|
430
|
+
|
|
431
|
+
void OnConversationLoaded(object conversation)
|
|
432
|
+
{
|
|
433
|
+
var id = GetMember(GetMember(conversation, "Id"), "Value") as string;
|
|
434
|
+
if (_loadTcs != null && id == _pendingLoadId)
|
|
435
|
+
_loadTcs.TrySetResult(conversation);
|
|
153
436
|
}
|
|
154
437
|
|
|
155
|
-
|
|
156
|
-
=> JsonUtility.FromJson<JsonStringWrapper>("{\"value\":\"" + value + "\"}")?.value ?? "";
|
|
438
|
+
// --- Markdown building --------------------------------------------------
|
|
157
439
|
|
|
158
|
-
static string
|
|
440
|
+
static string BuildConversationMarkdown(object conversation, bool includeToolCalls)
|
|
159
441
|
{
|
|
442
|
+
if (conversation == null) return "";
|
|
443
|
+
|
|
160
444
|
var builder = new StringBuilder();
|
|
445
|
+
var title = GetMember(conversation, "Title") as string;
|
|
446
|
+
builder.Append("# ").Append(DisplayTitle(title));
|
|
161
447
|
|
|
162
|
-
|
|
448
|
+
if (GetMember(conversation, "Messages") is IEnumerable messages)
|
|
163
449
|
{
|
|
164
|
-
var
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
builder.Append("\n\n");
|
|
450
|
+
foreach (var message in messages)
|
|
451
|
+
{
|
|
452
|
+
var text = ExtractMessageText(message, includeToolCalls);
|
|
453
|
+
if (string.IsNullOrWhiteSpace(text)) continue;
|
|
169
454
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
455
|
+
var role = GetMember(message, "Role") as string;
|
|
456
|
+
builder.Append("\n\n## ").Append(FormatRole(role)).Append("\n\n");
|
|
457
|
+
builder.Append(text.Trim());
|
|
458
|
+
}
|
|
174
459
|
}
|
|
175
460
|
|
|
176
461
|
return builder.ToString();
|
|
177
462
|
}
|
|
178
463
|
|
|
179
|
-
static string
|
|
180
|
-
|
|
464
|
+
static string ExtractMessageText(object message, bool includeToolCalls)
|
|
465
|
+
{
|
|
466
|
+
if (GetMember(message, "Blocks") is not IEnumerable blocks) return "";
|
|
181
467
|
|
|
182
|
-
|
|
183
|
-
|
|
468
|
+
var parts = new List<string>();
|
|
469
|
+
foreach (var block in blocks)
|
|
470
|
+
{
|
|
471
|
+
var text = BlockText(block, includeToolCalls);
|
|
472
|
+
if (!string.IsNullOrWhiteSpace(text)) parts.Add(text.Trim());
|
|
473
|
+
}
|
|
474
|
+
return string.Join("\n\n", parts);
|
|
475
|
+
}
|
|
184
476
|
|
|
185
|
-
|
|
186
|
-
|
|
477
|
+
// Pulls text out of each block. Text-bearing blocks return their content;
|
|
478
|
+
// FunctionCallBlock is rendered as the tool name plus its parameters (only when
|
|
479
|
+
// tool calls are included). The ACP tool-call / plan blocks carry structured
|
|
480
|
+
// data and are skipped.
|
|
481
|
+
static string BlockText(object block, bool includeToolCalls)
|
|
187
482
|
{
|
|
188
|
-
|
|
483
|
+
if (block == null) return null;
|
|
484
|
+
return block.GetType().Name switch
|
|
485
|
+
{
|
|
486
|
+
"PromptBlock" => GetMember(block, "Content") as string,
|
|
487
|
+
"AnswerBlock" => GetMember(block, "Content") as string,
|
|
488
|
+
"ThoughtBlock" => GetMember(block, "Content") as string,
|
|
489
|
+
"ErrorBlock" => GetMember(block, "Error") as string,
|
|
490
|
+
"InfoBlock" => GetMember(block, "Message") as string,
|
|
491
|
+
"FunctionCallBlock" => includeToolCalls ? FormatFunctionCall(GetMember(block, "Call")) : null,
|
|
492
|
+
_ => null
|
|
493
|
+
};
|
|
189
494
|
}
|
|
190
495
|
|
|
191
|
-
|
|
496
|
+
// Renders an AssistantFunctionCall (e.g. CodeEdit) as a tool name plus its
|
|
497
|
+
// parameters. Parameters is a Newtonsoft JObject, whose ToString() yields
|
|
498
|
+
// indented JSON, so no compile-time dependency on Newtonsoft is needed.
|
|
499
|
+
static string FormatFunctionCall(object call)
|
|
192
500
|
{
|
|
193
|
-
|
|
194
|
-
public readonly int PromptLine;
|
|
195
|
-
public readonly string PromptMessageId;
|
|
196
|
-
public readonly List<string> ResponseParts = new();
|
|
197
|
-
public readonly List<int> ResponseLines = new();
|
|
198
|
-
public string ResponseMessageId;
|
|
501
|
+
if (call == null) return null;
|
|
199
502
|
|
|
200
|
-
|
|
503
|
+
var name = GetMember(call, "FunctionId") as string;
|
|
504
|
+
var builder = new StringBuilder();
|
|
505
|
+
builder.Append("**Tool call: ").Append(string.IsNullOrEmpty(name) ? "(unknown)" : name).Append("**");
|
|
201
506
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
507
|
+
var parameters = GetMember(call, "Parameters")?.ToString();
|
|
508
|
+
if (!string.IsNullOrWhiteSpace(parameters))
|
|
509
|
+
builder.Append("\n\n```json\n").Append(parameters).Append("\n```");
|
|
510
|
+
|
|
511
|
+
return builder.ToString();
|
|
208
512
|
}
|
|
209
513
|
|
|
210
|
-
|
|
514
|
+
// --- Misc helpers -------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
static string DisplayTitle(string title)
|
|
517
|
+
=> string.IsNullOrEmpty(title) ? "(Untitled)" : title;
|
|
518
|
+
|
|
519
|
+
// Collapses CR/LF runs to single spaces so a value renders on one line.
|
|
520
|
+
static string SingleLine(string text)
|
|
521
|
+
=> string.IsNullOrEmpty(text)
|
|
522
|
+
? text
|
|
523
|
+
: System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
|
524
|
+
|
|
525
|
+
static string FormatRole(string role)
|
|
211
526
|
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
527
|
+
if (string.IsNullOrEmpty(role)) return "Message";
|
|
528
|
+
return char.ToUpperInvariant(role[0]) + role.Substring(1);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
static string FormatDate(long timestamp)
|
|
532
|
+
=> TryGetLocalTime(timestamp, out var time) ? time.ToString("yyyy-MM-dd HH:mm") : "";
|
|
216
533
|
|
|
217
|
-
|
|
534
|
+
// Date and time for filenames; colon-free so it is filesystem-safe and sortable.
|
|
535
|
+
static string FormatFileTimestamp(long timestamp)
|
|
536
|
+
=> TryGetLocalTime(timestamp, out var time) ? time.ToString("yyyy-MM-dd HH-mm") : "";
|
|
537
|
+
|
|
538
|
+
static bool TryGetLocalTime(long timestamp, out DateTimeOffset time)
|
|
539
|
+
{
|
|
540
|
+
time = default;
|
|
541
|
+
if (timestamp <= 0) return false;
|
|
542
|
+
try
|
|
543
|
+
{
|
|
544
|
+
time = (timestamp > 1_000_000_000_000L
|
|
545
|
+
? DateTimeOffset.FromUnixTimeMilliseconds(timestamp)
|
|
546
|
+
: DateTimeOffset.FromUnixTimeSeconds(timestamp)).ToLocalTime();
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
catch
|
|
218
550
|
{
|
|
219
|
-
|
|
220
|
-
MessageId = messageId;
|
|
221
|
-
LastMessage = lastMessage;
|
|
222
|
-
Markdown = markdown;
|
|
551
|
+
return false;
|
|
223
552
|
}
|
|
224
553
|
}
|
|
554
|
+
|
|
555
|
+
static string SanitizeFileName(string name)
|
|
556
|
+
{
|
|
557
|
+
if (string.IsNullOrEmpty(name)) return "conversation";
|
|
558
|
+
var invalid = Path.GetInvalidFileNameChars();
|
|
559
|
+
var sanitized = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
|
|
560
|
+
return string.IsNullOrEmpty(sanitized) ? "conversation" : sanitized;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
static Type FindType(string fullName)
|
|
564
|
+
=> AppDomain.CurrentDomain.GetAssemblies()
|
|
565
|
+
.Select(a => a.GetType(fullName, false))
|
|
566
|
+
.FirstOrDefault(t => t != null);
|
|
567
|
+
|
|
568
|
+
// Reads a public/internal field or property by name from a live object
|
|
569
|
+
// (works on boxed structs too).
|
|
570
|
+
static object GetMember(object obj, string name)
|
|
571
|
+
{
|
|
572
|
+
if (obj == null) return null;
|
|
573
|
+
var type = obj.GetType();
|
|
574
|
+
var field = type.GetField(name, InstanceMembers);
|
|
575
|
+
if (field != null) return field.GetValue(obj);
|
|
576
|
+
return type.GetProperty(name, InstanceMembers)?.GetValue(obj);
|
|
577
|
+
}
|
|
225
578
|
}
|
|
226
579
|
|
|
227
580
|
} // namespace AIAssistantExtensions
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
.unity-text-element {
|
|
2
|
+
-unity-font-definition: url("project://database/Packages/jp.keijiro.ai.assistant.extensions/Editor/NotoSansJP-Regular.ttf?fileID=12800000&guid=f7d2dd1a328194df9b71491ff2d07e2b&type=3#NotoSansJP-Regular");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.spacer {
|
|
6
|
+
flex-grow: 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.split {
|
|
10
|
+
flex-grow: 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#left-pane {
|
|
14
|
+
min-width: 150px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#right-pane {
|
|
18
|
+
min-width: 200px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.pane {
|
|
22
|
+
flex-grow: 1;
|
|
23
|
+
min-height: 0;
|
|
24
|
+
padding: 4px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.header {
|
|
28
|
+
-unity-font-style: bold;
|
|
29
|
+
margin-top: 2px;
|
|
30
|
+
margin-bottom: 2px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.preview-header {
|
|
34
|
+
flex-direction: row;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
align-items: center;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.toolcalls {
|
|
40
|
+
margin-bottom: 2px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.empty {
|
|
44
|
+
white-space: normal;
|
|
45
|
+
opacity: 0.6;
|
|
46
|
+
margin: 4px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.list {
|
|
50
|
+
flex-grow: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.preview {
|
|
54
|
+
flex-grow: 1;
|
|
55
|
+
flex-shrink: 1;
|
|
56
|
+
min-height: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.preview .unity-base-text-field__input {
|
|
60
|
+
white-space: normal;
|
|
61
|
+
-unity-text-align: upper-left;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.row {
|
|
65
|
+
justify-content: center;
|
|
66
|
+
padding-left: 6px;
|
|
67
|
+
padding-right: 6px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.row-title {
|
|
71
|
+
-unity-font-style: bold;
|
|
72
|
+
text-overflow: ellipsis;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.row-date {
|
|
78
|
+
font-size: 10px;
|
|
79
|
+
opacity: 0.65;
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fileFormatVersion: 2
|
|
2
|
+
guid: f6331e5dc7a84404baedc43bf6c5b522
|
|
3
|
+
ScriptedImporter:
|
|
4
|
+
internalIDToNameTable: []
|
|
5
|
+
externalObjects: {}
|
|
6
|
+
serializedVersion: 2
|
|
7
|
+
userData:
|
|
8
|
+
assetBundleName:
|
|
9
|
+
assetBundleVariant:
|
|
10
|
+
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
|
11
|
+
disableValidation: 0
|
|
12
|
+
unsupportedSelectorAction: 0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
|
|
2
|
+
<Style src="project://database/Packages/jp.keijiro.ai.assistant.extensions/Editor/ConversationExtractorWindow.uss?fileID=7433441132597879392&guid=f6331e5dc7a84404baedc43bf6c5b522&type=3#ConversationExtractorWindow"/>
|
|
3
|
+
<uie:Toolbar>
|
|
4
|
+
<uie:ToolbarButton name="refresh-button" text="Refresh"/>
|
|
5
|
+
<ui:VisualElement class="spacer"/>
|
|
6
|
+
<uie:ToolbarButton name="copy-button" text="Copy"/>
|
|
7
|
+
<uie:ToolbarButton name="save-button" text="Save"/>
|
|
8
|
+
</uie:Toolbar>
|
|
9
|
+
<ui:TwoPaneSplitView name="split" orientation="Horizontal" fixed-pane-index="0" fixed-pane-initial-dimension="240" view-data-key="split" class="split">
|
|
10
|
+
<ui:VisualElement name="left-pane" class="pane">
|
|
11
|
+
<ui:Label name="list-header" class="header"/>
|
|
12
|
+
<ui:ListView name="conversation-list" view-data-key="list" fixed-item-height="38" selection-type="Single" class="list"/>
|
|
13
|
+
<ui:Label name="empty-label" class="empty"/>
|
|
14
|
+
</ui:VisualElement>
|
|
15
|
+
<ui:VisualElement name="right-pane" class="pane">
|
|
16
|
+
<ui:VisualElement name="preview-header" class="preview-header">
|
|
17
|
+
<ui:Label text="Extracted Markdown" class="header"/>
|
|
18
|
+
<ui:Toggle name="toolcalls-toggle" text="Tool Calls" class="toolcalls"/>
|
|
19
|
+
</ui:VisualElement>
|
|
20
|
+
<ui:TextField name="preview" multiline="true" readonly="true" vertical-scroller-visibility="Auto" focusable="false" class="preview"/>
|
|
21
|
+
</ui:VisualElement>
|
|
22
|
+
</ui:TwoPaneSplitView>
|
|
23
|
+
</ui:UXML>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fileFormatVersion: 2
|
|
2
|
+
guid: 7a48f083d5c894030ae036790fba1881
|
|
3
|
+
ScriptedImporter:
|
|
4
|
+
internalIDToNameTable: []
|
|
5
|
+
externalObjects: {}
|
|
6
|
+
serializedVersion: 2
|
|
7
|
+
userData:
|
|
8
|
+
assetBundleName:
|
|
9
|
+
assetBundleVariant:
|
|
10
|
+
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fileFormatVersion: 2
|
|
2
|
+
guid: d67de4ce2aaaa4897b5c8a3355889f53
|
|
3
|
+
ScriptedImporter:
|
|
4
|
+
internalIDToNameTable: []
|
|
5
|
+
externalObjects: {}
|
|
6
|
+
serializedVersion: 2
|
|
7
|
+
userData:
|
|
8
|
+
assetBundleName:
|
|
9
|
+
assetBundleVariant:
|
|
10
|
+
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jp.keijiro.ai.assistant.extensions",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"displayName": "AI Assistant Extensions",
|
|
5
5
|
"description": "Custom Unity package that provides extensions for Unity AI Assistant.",
|
|
6
6
|
"unity": "6000.0",
|
|
7
7
|
"author": "Keijiro Takahashi",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"com.unity.ai.assistant": "2.
|
|
9
|
+
"com.unity.ai.assistant": "2.11.0-pre.1"
|
|
10
10
|
},
|
|
11
11
|
"changelogUrl": "https://github.com/keijiro/AIA-Extensions/blob/master/CHANGELOG.md",
|
|
12
12
|
"documentationUrl": "https://github.com/keijiro/AIA-Extensions",
|
|
13
13
|
"licensesUrl": "https://github.com/keijiro/AIA-Extensions/blob/master/LICENSE",
|
|
14
14
|
"license": "Unlicense",
|
|
15
15
|
"_upm": {
|
|
16
|
-
"changelog": "<b>
|
|
16
|
+
"changelog": "<b>Changed</b><br>- Prefixed saved conversation filenames with the conversation's date and time so exported files sort chronologically."
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"url": "git@github.com:keijiro/AIA-Extensions.git",
|
|
20
20
|
"type": "git",
|
|
21
|
-
"revision": "
|
|
21
|
+
"revision": "6c89672e11d612d4416afa8f114af153fae8ec6a"
|
|
22
22
|
}
|
|
23
23
|
}
|