Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions recipes/chatgpt-local/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions recipes/chatgpt-local/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = Ferdium => Ferdium;
9 changes: 9 additions & 0 deletions recipes/chatgpt-local/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "chatgpt-local",
"name": "ChatGPT Local",
"version": "1.0.3",
"license": "MIT",
"config": {
"serviceURL": "https://chatgpt.com/"
}
}
21 changes: 21 additions & 0 deletions recipes/chatgpt-local/parser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# AI Parser Vendor

This directory vendors the multi-platform AI chat DOM parsing strategy from AllinOB's `ai-chat-exporter`, converted to executable CommonJS for recipe-side use.

Included platform parsers:
- chatgpt
- claude
- copilot
- deepseek
- gemini
- kimi
- tongyi

Shared parser infrastructure:
- `shared/markdown.js`: HTML to Markdown conversion
- `shared/dom.js`: generic UI-noise cleanup
- `shared/parserCore.js`: shared title/model/message parsing helpers extracted during integration

Current runtime integration in this recipe only uses the `chatgpt` parser.
`gemini` remains specialized because it contains deep-research and canvas-specific handling.
The other platform parsers now share a lighter common parsing skeleton and remain available for future recipe work.
53 changes: 53 additions & 0 deletions recipes/chatgpt-local/parser/incremental/completionDetector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const STABLE_WINDOW_MS = 1600;

function getPendingIdentity(message) {
if (!message) {
return null;
}

return `${message.role}:${message.seq}`;
}

function evaluateAssistantCompletion(runtime, message, now = Date.now()) {
if (!message || message.role !== 'assistant') {
return {
done: true,
pendingAssistant: null,
};
}

const identity = getPendingIdentity(message);
const previousPending = runtime.pendingAssistant;

if (!previousPending || previousPending.identity !== identity) {
return {
done: false,
pendingAssistant: {
identity,
lastText: message.text,
stableSince: now,
},
};
}

if (previousPending.lastText !== message.text) {
return {
done: false,
pendingAssistant: {
identity,
lastText: message.text,
stableSince: now,
},
};
}

return {
done: now - previousPending.stableSince >= STABLE_WINDOW_MS,
pendingAssistant: previousPending,
};
}

module.exports = {
STABLE_WINDOW_MS,
evaluateAssistantCompletion,
};
55 changes: 55 additions & 0 deletions recipes/chatgpt-local/parser/incremental/conversationRuntime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
function createConversationRuntime() {
return {
conversationKey: null,
observer: null,
debounceTimer: null,
completionTimer: null,
baselineMessages: [],
status: 'idle',
pendingAssistant: null,
};
}

function resetRuntime(runtime, conversationKey = null) {
runtime.conversationKey = conversationKey;
runtime.baselineMessages = [];
runtime.pendingAssistant = null;
runtime.status = 'idle';
}

function destroyObserver(runtime) {
if (runtime.observer) {
runtime.observer.disconnect();
runtime.observer = null;
}
}

function clearDebounceTimer(runtime) {
if (runtime.debounceTimer) {
window.clearTimeout(runtime.debounceTimer);
runtime.debounceTimer = null;
}
}

function clearCompletionTimer(runtime) {
if (runtime.completionTimer) {
window.clearTimeout(runtime.completionTimer);
runtime.completionTimer = null;
}
}

function destroyRuntime(runtime) {
destroyObserver(runtime);
clearDebounceTimer(runtime);
clearCompletionTimer(runtime);
resetRuntime(runtime);
}

module.exports = {
createConversationRuntime,
resetRuntime,
destroyObserver,
clearDebounceTimer,
clearCompletionTimer,
destroyRuntime,
};
98 changes: 98 additions & 0 deletions recipes/chatgpt-local/parser/incremental/diffMessages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
function getStableMessageIdentity(message) {
if (!message) {
return null;
}

return `${message.role}:${message.seq}`;
}

function diffMessages(previousMessages, nextMessages) {
if (previousMessages.length === 0) {
return {
type: 'bootstrap',
appended: nextMessages,
updatedTail: null,
};
}

if (nextMessages.length < previousMessages.length) {
return {
type: 'rescan',
appended: [],
updatedTail: null,
};
}

for (let index = 0; index < previousMessages.length; index += 1) {
const previousMessage = previousMessages[index];
const nextMessage = nextMessages[index];

if (!nextMessage) {
return {
type: 'rescan',
appended: [],
updatedTail: null,
};
}

const previousIdentity = getStableMessageIdentity(previousMessage);
const nextIdentity = getStableMessageIdentity(nextMessage);
const isTailAssistantUpdate =
index === previousMessages.length - 1 &&
previousMessage.role === 'assistant' &&
nextMessage.role === 'assistant' &&
previousIdentity === nextIdentity &&
previousMessage.text !== nextMessage.text;

if (isTailAssistantUpdate) {
continue;
}

if (
previousIdentity !== nextIdentity ||
previousMessage.text !== nextMessage.text
) {
return {
type: 'rescan',
appended: [],
updatedTail: null,
};
}
}

if (nextMessages.length > previousMessages.length) {
return {
type: 'append',
appended: nextMessages.slice(previousMessages.length),
updatedTail: null,
};
}

const previousTail = previousMessages.at(-1);
const nextTail = nextMessages.at(-1);

if (
previousTail &&
nextTail &&
previousTail.role === 'assistant' &&
nextTail.role === 'assistant' &&
previousTail.seq === nextTail.seq &&
previousTail.text !== nextTail.text
) {
return {
type: 'update-tail',
appended: [],
updatedTail: nextTail,
};
}

return {
type: 'noop',
appended: [],
updatedTail: null,
};
}

module.exports = {
diffMessages,
};
16 changes: 16 additions & 0 deletions recipes/chatgpt-local/parser/incremental/emitScanResult.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const INCREMENTAL_HOST_CHANNEL = 'aihub-incremental-messages';

function emitIncrementalResult(Ferdium, payload, logPrefix) {
if (!Ferdium?.ipcRenderer?.sendToHost) {
console.warn(`${logPrefix} sendToHost unavailable for incremental payload`);
return false;
}

Ferdium.ipcRenderer.sendToHost(INCREMENTAL_HOST_CHANNEL, payload);
return true;
}

module.exports = {
INCREMENTAL_HOST_CHANNEL,
emitIncrementalResult,
};
50 changes: 50 additions & 0 deletions recipes/chatgpt-local/parser/incremental/mutationObserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const OBSERVER_ROOT_SELECTORS = [
'main',
'[data-testid="conversation-turn-wrapper"]',
'[data-message-author-role]',
'article',
];

function findObservationRoot(doc) {
for (const selector of OBSERVER_ROOT_SELECTORS) {
const element = doc.querySelector(selector);

if (element) {
return element.closest('main') || element;
}
}

return doc.querySelector('main') || doc.body || null;
}

function installMutationObserver({ doc, onMutate }) {
const root = findObservationRoot(doc);

if (!root) {
return null;
}

const observer = new MutationObserver(mutations => {
const hasRelevantMutation = mutations.some(
mutation =>
mutation.type === 'childList' || mutation.type === 'characterData',
);

if (hasRelevantMutation) {
onMutate();
}
});

observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});

return observer;
}

module.exports = {
findObservationRoot,
installMutationObserver,
};
36 changes: 36 additions & 0 deletions recipes/chatgpt-local/parser/parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if ((from && typeof from === 'object') || typeof from === 'function') {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable,
});
}
return to;
};
var __toCommonJS = mod =>
__copyProps(__defProp({}, '__esModule', { value: true }), mod);
var parse_exports = {};
__export(parse_exports, {
chatHtmlToMarkdown: () => import_markdown.chatHtmlToMarkdown,
parseChatDOM: () => parseChatDOM,
});
module.exports = __toCommonJS(parse_exports);
var import_registry = require('./registry');
var import_markdown = require('./shared/markdown');
function parseChatDOM(platform, doc, config) {
const parser = (0, import_registry.resolveParser)(platform);
if (!parser) {
return import_registry.EMPTY_RESULT;
}
return parser.parse(doc, config);
}
Loading