mirror of
https://github.com/agoramachina/claude-exporter.git
synced 2026-07-09 15:59:52 +00:00
Merge branch 'testing'
# Conflicts: # chrome/manifest.json # firefox/manifest.json
This commit is contained in:
commit
93113209f2
10 changed files with 186 additions and 18 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,5 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
## [1.9.4]
|
||||
|
||||
- Browse table's Model column now shows each chat's original (first-seen) model from the snapshot data, falling back to the current/inferred model when no snapshot exists
|
||||
- Bounced chats (original model differs from current) get a `→` marker with a tooltip showing "Originally X, now Y"
|
||||
- Model column sorting follows the displayed (original) model
|
||||
|
||||
## [1.9.3]
|
||||
|
||||
- Snapshot each conversation's current model to `chrome.storage.local` whenever the conversation list is fetched (browse page load or popup "Export All") — preserves the model before a chat gets bounced to a new one on model retirement
|
||||
- Records first-seen model, current model, and a change history per conversation; only the raw API model is stored, never an inferred guess
|
||||
|
||||
## [1.9.2]
|
||||
|
||||
- Added Vitest test harness for `utils.js` (52 tests covering export logic, model name parsing, artifact extraction)
|
||||
|
|
|
|||
3
TODO.md
3
TODO.md
|
|
@ -152,6 +152,9 @@
|
|||
- Future: consider sourcing from a JSON config file or remote endpoint instead of hardcoded array
|
||||
|
||||
- **Track model changes per conversation**
|
||||
- **Phase 1 capture SHIPPED (v1.9.3)** — `recordModelSnapshots()` in `content.js` writes `modelSnapshots` to `chrome.storage.local` every time the conversation list is fetched (browse page load or popup "Export All"), not just on export. Stores `{firstSeen, firstSeenAt, current, currentAt, history[]}` per conversation UUID; raw API model only, never an inferred guess.
|
||||
- **Browse-table display SHIPPED (v1.9.4)** — Model column shows the original (first-seen) model via `getDisplayModel()`, with a `→` bounce marker + tooltip when the current model differs. Falls back to current/inferred when no snapshot exists.
|
||||
- Still pending: surface the snapshot in JSON exports (sidecar or inline field); optionally a dedicated "current model" column or filter for bounced chats
|
||||
- `conversation.model` from the API is the *current* model only — when chats get bounced (deprecation, guardrails kicking to Sonnet 4, etc.) the original model is lost
|
||||
- Symptom: chats created before Sonnet 4.5 existed now show "Sonnet 4.5" because that's their current default
|
||||
-
|
||||
|
|
|
|||
|
|
@ -544,7 +544,15 @@
|
|||
.model-badge.opus {
|
||||
color: var(--badge-opus);
|
||||
}
|
||||
|
||||
|
||||
.model-bounced {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--badge-default);
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ let sortStack = []; // Track multi-level sorting: [{field: 'name', direction: 'a
|
|||
let selectedConversations = new Set(); // Track selected conversation IDs
|
||||
let lastCheckedIndex = null; // Track last checked checkbox for shift+click range selection
|
||||
let exportTimestamps = {}; // Map conversation UUID to last export timestamp
|
||||
let modelSnapshots = {}; // Map conversation UUID to { firstSeen, current, ... } captured by content.js
|
||||
let statusFilter = 'all'; // 'all', 'new', 'exported'
|
||||
let dateFormat = 'mdy'; // 'mdy' or 'dmy'
|
||||
let timeFormat = '12h'; // '12h' or '24h'
|
||||
|
|
@ -61,6 +62,33 @@ async function loadExportTimestamps() {
|
|||
});
|
||||
}
|
||||
|
||||
// Model snapshots are written by content.js whenever the conversation list is
|
||||
// fetched (see recordModelSnapshots). They preserve the original model even
|
||||
// after a chat is bounced to a newer one on model retirement.
|
||||
async function loadModelSnapshots() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['modelSnapshots'], (result) => {
|
||||
modelSnapshots = result.modelSnapshots || {};
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve which model to show for a conversation: the original (first-seen)
|
||||
// snapshot if we have one, otherwise the current/inferred model. Also reports
|
||||
// whether the chat has since been bounced to a different model.
|
||||
function getDisplayModel(conv) {
|
||||
const snap = modelSnapshots[conv.uuid];
|
||||
if (snap && snap.firstSeen) {
|
||||
return {
|
||||
model: snap.firstSeen,
|
||||
current: snap.current || snap.firstSeen,
|
||||
bounced: !!snap.current && snap.current !== snap.firstSeen
|
||||
};
|
||||
}
|
||||
return { model: conv.model, current: conv.model, bounced: false };
|
||||
}
|
||||
|
||||
async function saveExportTimestamp(conversationId) {
|
||||
exportTimestamps[conversationId] = new Date().toISOString();
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -114,6 +142,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const loadingStart = Date.now();
|
||||
await loadOrgId();
|
||||
await loadExportTimestamps();
|
||||
await loadModelSnapshots();
|
||||
await loadDateTimePrefs();
|
||||
const elapsed = Date.now() - loadingStart;
|
||||
if (elapsed < 1000) await new Promise(r => setTimeout(r, 1000 - elapsed));
|
||||
|
|
@ -307,8 +336,8 @@ function sortConversations() {
|
|||
bVal = new Date(b.updated_at);
|
||||
break;
|
||||
case 'model':
|
||||
aVal = formatModelName(a.model || '').toLowerCase();
|
||||
bVal = formatModelName(b.model || '').toLowerCase();
|
||||
aVal = formatModelName(getDisplayModel(a).model || '').toLowerCase();
|
||||
bVal = formatModelName(getDisplayModel(b).model || '').toLowerCase();
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
|
|
@ -393,7 +422,8 @@ function displayConversations() {
|
|||
const updatedTime = formatTime(updatedDt);
|
||||
const createdDate = formatDate(createdDt);
|
||||
const createdTime = formatTime(createdDt);
|
||||
const modelBadgeClass = getModelBadgeClass(conv.model);
|
||||
const modelInfo = getDisplayModel(conv);
|
||||
const modelBadgeClass = getModelBadgeClass(modelInfo.model);
|
||||
const projectName = getProjectName(conv);
|
||||
|
||||
const newUpdated = isNewOrUpdated(conv);
|
||||
|
|
@ -412,8 +442,8 @@ function displayConversations() {
|
|||
<td class="date">${escapeHtml(createdDate)}<br><span class="time">${escapeHtml(createdTime)}</span></td>
|
||||
<td>
|
||||
<span class="model-badge ${modelBadgeClass}">
|
||||
${escapeHtml(formatModelName(conv.model))}
|
||||
</span>
|
||||
${escapeHtml(formatModelName(modelInfo.model))}
|
||||
</span>${modelInfo.bounced ? `<span class="model-bounced" title="Originally ${escapeHtml(formatModelName(modelInfo.model))}, now ${escapeHtml(formatModelName(modelInfo.current))}">→</span>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,43 @@ function recordExportTimestamps(conversationIds) {
|
|||
});
|
||||
}
|
||||
|
||||
// Snapshot each conversation's current model so it survives a model bounce
|
||||
// (e.g. when a model retires and Claude silently moves old chats onto a new
|
||||
// one). Only the raw API model is recorded — never an inferred guess.
|
||||
function recordModelSnapshots(conversations) {
|
||||
if (!Array.isArray(conversations)) return;
|
||||
chrome.storage.local.get(['modelSnapshots'], (result) => {
|
||||
const snapshots = result.modelSnapshots || {};
|
||||
const now = new Date().toISOString();
|
||||
let changed = false;
|
||||
for (const conv of conversations) {
|
||||
const model = conv && conv.model;
|
||||
const id = conv && conv.uuid;
|
||||
if (!model || !id) continue; // skip null-model chats — don't snapshot a guess
|
||||
const existing = snapshots[id];
|
||||
if (!existing) {
|
||||
snapshots[id] = {
|
||||
firstSeen: model,
|
||||
firstSeenAt: now,
|
||||
current: model,
|
||||
currentAt: now,
|
||||
history: [{ model, at: now }]
|
||||
};
|
||||
changed = true;
|
||||
} else if (existing.current !== model) {
|
||||
existing.current = model;
|
||||
existing.currentAt = now;
|
||||
existing.history = existing.history || [];
|
||||
existing.history.push({ model, at: now });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
chrome.storage.local.set({ modelSnapshots: snapshots });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to format datetime in local time for filenames
|
||||
function getLocalDateTimeString() {
|
||||
const now = new Date();
|
||||
|
|
@ -72,8 +109,10 @@ function getLocalDateTimeString() {
|
|||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch conversations: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
const conversations = await response.json();
|
||||
recordModelSnapshots(conversations); // capture current models before any bounce
|
||||
return conversations;
|
||||
}
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Claude Exporter",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.4",
|
||||
"description": "Export conversations and artifacts from Claude.ai",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
|
|
|||
|
|
@ -544,7 +544,15 @@
|
|||
.model-badge.opus {
|
||||
color: var(--badge-opus);
|
||||
}
|
||||
|
||||
|
||||
.model-bounced {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--badge-default);
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ let sortStack = []; // Track multi-level sorting: [{field: 'name', direction: 'a
|
|||
let selectedConversations = new Set(); // Track selected conversation IDs
|
||||
let lastCheckedIndex = null; // Track last checked checkbox for shift+click range selection
|
||||
let exportTimestamps = {}; // Map conversation UUID to last export timestamp
|
||||
let modelSnapshots = {}; // Map conversation UUID to { firstSeen, current, ... } captured by content.js
|
||||
let statusFilter = 'all'; // 'all', 'new', 'exported'
|
||||
let dateFormat = 'mdy'; // 'mdy' or 'dmy'
|
||||
let timeFormat = '12h'; // '12h' or '24h'
|
||||
|
|
@ -61,6 +62,33 @@ async function loadExportTimestamps() {
|
|||
});
|
||||
}
|
||||
|
||||
// Model snapshots are written by content.js whenever the conversation list is
|
||||
// fetched (see recordModelSnapshots). They preserve the original model even
|
||||
// after a chat is bounced to a newer one on model retirement.
|
||||
async function loadModelSnapshots() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['modelSnapshots'], (result) => {
|
||||
modelSnapshots = result.modelSnapshots || {};
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve which model to show for a conversation: the original (first-seen)
|
||||
// snapshot if we have one, otherwise the current/inferred model. Also reports
|
||||
// whether the chat has since been bounced to a different model.
|
||||
function getDisplayModel(conv) {
|
||||
const snap = modelSnapshots[conv.uuid];
|
||||
if (snap && snap.firstSeen) {
|
||||
return {
|
||||
model: snap.firstSeen,
|
||||
current: snap.current || snap.firstSeen,
|
||||
bounced: !!snap.current && snap.current !== snap.firstSeen
|
||||
};
|
||||
}
|
||||
return { model: conv.model, current: conv.model, bounced: false };
|
||||
}
|
||||
|
||||
async function saveExportTimestamp(conversationId) {
|
||||
exportTimestamps[conversationId] = new Date().toISOString();
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -114,6 +142,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const loadingStart = Date.now();
|
||||
await loadOrgId();
|
||||
await loadExportTimestamps();
|
||||
await loadModelSnapshots();
|
||||
await loadDateTimePrefs();
|
||||
const elapsed = Date.now() - loadingStart;
|
||||
if (elapsed < 1000) await new Promise(r => setTimeout(r, 1000 - elapsed));
|
||||
|
|
@ -307,8 +336,8 @@ function sortConversations() {
|
|||
bVal = new Date(b.updated_at);
|
||||
break;
|
||||
case 'model':
|
||||
aVal = formatModelName(a.model || '').toLowerCase();
|
||||
bVal = formatModelName(b.model || '').toLowerCase();
|
||||
aVal = formatModelName(getDisplayModel(a).model || '').toLowerCase();
|
||||
bVal = formatModelName(getDisplayModel(b).model || '').toLowerCase();
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
|
|
@ -393,7 +422,8 @@ function displayConversations() {
|
|||
const updatedTime = formatTime(updatedDt);
|
||||
const createdDate = formatDate(createdDt);
|
||||
const createdTime = formatTime(createdDt);
|
||||
const modelBadgeClass = getModelBadgeClass(conv.model);
|
||||
const modelInfo = getDisplayModel(conv);
|
||||
const modelBadgeClass = getModelBadgeClass(modelInfo.model);
|
||||
const projectName = getProjectName(conv);
|
||||
|
||||
const newUpdated = isNewOrUpdated(conv);
|
||||
|
|
@ -412,8 +442,8 @@ function displayConversations() {
|
|||
<td class="date">${escapeHtml(createdDate)}<br><span class="time">${escapeHtml(createdTime)}</span></td>
|
||||
<td>
|
||||
<span class="model-badge ${modelBadgeClass}">
|
||||
${escapeHtml(formatModelName(conv.model))}
|
||||
</span>
|
||||
${escapeHtml(formatModelName(modelInfo.model))}
|
||||
</span>${modelInfo.bounced ? `<span class="model-bounced" title="Originally ${escapeHtml(formatModelName(modelInfo.model))}, now ${escapeHtml(formatModelName(modelInfo.current))}">→</span>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,43 @@ function recordExportTimestamps(conversationIds) {
|
|||
});
|
||||
}
|
||||
|
||||
// Snapshot each conversation's current model so it survives a model bounce
|
||||
// (e.g. when a model retires and Claude silently moves old chats onto a new
|
||||
// one). Only the raw API model is recorded — never an inferred guess.
|
||||
function recordModelSnapshots(conversations) {
|
||||
if (!Array.isArray(conversations)) return;
|
||||
chrome.storage.local.get(['modelSnapshots'], (result) => {
|
||||
const snapshots = result.modelSnapshots || {};
|
||||
const now = new Date().toISOString();
|
||||
let changed = false;
|
||||
for (const conv of conversations) {
|
||||
const model = conv && conv.model;
|
||||
const id = conv && conv.uuid;
|
||||
if (!model || !id) continue; // skip null-model chats — don't snapshot a guess
|
||||
const existing = snapshots[id];
|
||||
if (!existing) {
|
||||
snapshots[id] = {
|
||||
firstSeen: model,
|
||||
firstSeenAt: now,
|
||||
current: model,
|
||||
currentAt: now,
|
||||
history: [{ model, at: now }]
|
||||
};
|
||||
changed = true;
|
||||
} else if (existing.current !== model) {
|
||||
existing.current = model;
|
||||
existing.currentAt = now;
|
||||
existing.history = existing.history || [];
|
||||
existing.history.push({ model, at: now });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
chrome.storage.local.set({ modelSnapshots: snapshots });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to format datetime in local time for filenames
|
||||
function getLocalDateTimeString() {
|
||||
const now = new Date();
|
||||
|
|
@ -79,8 +116,10 @@ function getLocalDateTimeString() {
|
|||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch conversations: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
const conversations = await response.json();
|
||||
recordModelSnapshots(conversations); // capture current models before any bounce
|
||||
return conversations;
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Claude Exporter",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.4",
|
||||
"description": "Export conversations and artifacts from Claude.ai",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue