u
This commit is contained in:
@@ -17,6 +17,7 @@ import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissue
|
||||
import { createFindFilesTool } from '../tools/find-files.js';
|
||||
import { createReadFileTool } from '../tools/read-file.js';
|
||||
import { createPatchTool } from '../tools/patch.js';
|
||||
import { setCachedDraft, parseDraftPath } from '../services/draftManager.js';
|
||||
import { createRmTool } from '../tools/rm.js';
|
||||
import { createSearchImagesTool } from '../tools/search-images.js';
|
||||
import { createAnalyzeImageTool } from '../tools/analyze-image.js';
|
||||
@@ -501,6 +502,39 @@ export class ConversationManager {
|
||||
});
|
||||
toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' });
|
||||
await db.setState(googleAuthId, this.conversationId, 'busy');
|
||||
|
||||
// If this tool response is answering a draft read, cache the draft
|
||||
try {
|
||||
let parsedContent = null;
|
||||
try { parsedContent = JSON.parse(rawContent); } catch {}
|
||||
if (parsedContent && (parsedContent.manifest || parsedContent.lines)) {
|
||||
const messages = await db.listMessages(googleAuthId, this.conversationId);
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role === 'assistant' || m.role === 'model') {
|
||||
let p = null;
|
||||
try { p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; } catch {}
|
||||
const tc = p?.tool_calls?.find((c) => c.id === tool_call_id);
|
||||
if (tc) {
|
||||
let tcArgs = {};
|
||||
try { tcArgs = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {}); } catch {}
|
||||
const draftInfo = parseDraftPath(tcArgs.filename || tcArgs.file || '');
|
||||
if (draftInfo.isDraft) {
|
||||
setCachedDraft({
|
||||
companyId: this.companyId,
|
||||
userId: googleAuthId,
|
||||
name: draftInfo.name,
|
||||
content: parsedContent,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[conversationManager] could not cache draft from tool message:', err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
this.isProcessing = false;
|
||||
throw err;
|
||||
@@ -739,8 +773,8 @@ export class ConversationManager {
|
||||
// can never end up pointed at different companies.
|
||||
createJavascriptExecuteTool({ googleAuthId }),
|
||||
createFindFilesTool({ googleAuthId }),
|
||||
createReadFileTool({ googleAuthId }),
|
||||
createPatchTool({ googleAuthId }),
|
||||
createReadFileTool({ googleAuthId, conversationId: this.conversationId }),
|
||||
createPatchTool({ googleAuthId, conversationId: this.conversationId }),
|
||||
createRmTool({ googleAuthId }),
|
||||
createSearchImagesTool({ googleAuthId }),
|
||||
createAnalyzeImageTool({ googleAuthId, model }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { emit, hasActiveClient } from '../api/sse.js';
|
||||
import { emit } from '../api/sse.js';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { DirectReturnError } from 'libllmorbiter';
|
||||
|
||||
export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']);
|
||||
|
||||
@@ -37,6 +38,27 @@ export function parseDraftPath(filePath) {
|
||||
return { isFolder: false, isDraft: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty draft structure.
|
||||
* @param {string} name
|
||||
* @returns {object}
|
||||
*/
|
||||
export function defaultDraftContent(name) {
|
||||
return {
|
||||
manifest: {
|
||||
kind: name,
|
||||
mode: 'create',
|
||||
id: null,
|
||||
date: null,
|
||||
partyId: null,
|
||||
number: null,
|
||||
externalNumber: null,
|
||||
comment: null,
|
||||
lines: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size }
|
||||
*/
|
||||
@@ -111,200 +133,81 @@ export async function resolveCompanyForUser(googleAuthId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending requests map: requestId -> { resolve, reject, timer, companyId, userId, action, name }
|
||||
*/
|
||||
const pendingRequests = new Map();
|
||||
|
||||
/**
|
||||
* Request a draft content from the frontend via SSE and wait for its response.
|
||||
* Request a draft content.
|
||||
* Stateless model:
|
||||
* - If cached, returns immediately.
|
||||
* - In conversation: emits `draft:request` with conversationId and raises DirectReturnError(..., { recordResult: false })
|
||||
* so the assistant tool call remains the last message in `modelmessage` until frontend responds.
|
||||
* - Outside conversation: returns default empty draft envelope.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number, clientId?: string, name: string, timeoutMs?: number }} options
|
||||
* @param {{ companyId?: number, userId: number, conversationId?: number, clientId?: string, name: string }} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function requestDraftRead({ companyId, userId, clientId, name, timeoutMs = 15000 }) {
|
||||
export async function requestDraftRead({ companyId, userId, conversationId, clientId, name }) {
|
||||
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
|
||||
if (effectiveCompanyId == null) {
|
||||
throw new Error('No company selected');
|
||||
}
|
||||
|
||||
// Check if client is active; if not and we have cache, return cache immediately
|
||||
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
|
||||
if (!hasClient) {
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return cached.content;
|
||||
}
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
const requestId = `draft_req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
if (conversationId) {
|
||||
const requestId = `conv_${conversationId}_${name}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
// Fallback to cache if available
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return resolve(cached.content);
|
||||
}
|
||||
reject(new Error(`Frontend draft response timed out for "${name}"`));
|
||||
}, timeoutMs);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
companyId: effectiveCompanyId,
|
||||
userId,
|
||||
action: 'read',
|
||||
name,
|
||||
});
|
||||
|
||||
// Emit request over SSE
|
||||
// Emit request over SSE with conversationId
|
||||
emit('draft:request', {
|
||||
companyId: effectiveCompanyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
requestId,
|
||||
conversationId,
|
||||
action: 'read',
|
||||
name,
|
||||
target: name,
|
||||
sender: clientId || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Halt turn statelessly; assistant tool call stays as the last message in modelmessage
|
||||
throw new DirectReturnError({
|
||||
action: 'draft_read',
|
||||
draft: name,
|
||||
conversationId,
|
||||
}, { recordResult: false });
|
||||
}
|
||||
|
||||
return defaultDraftContent(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request draft listing from the frontend via SSE.
|
||||
* If frontend doesn't respond or times out, returns default metadata for all 3 drafts.
|
||||
* Request draft listing for VFS.
|
||||
* Returns immediately from cache or default empty draft metadata. Completely stateless without timers or promises.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number, clientId?: string, timeoutMs?: number }} options
|
||||
* @returns {Promise<Array<object>>}
|
||||
* @param {{ companyId?: number, userId: number }} options
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export async function requestDraftList({ companyId, userId, clientId, timeoutMs = 2500 }) {
|
||||
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
|
||||
if (effectiveCompanyId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const defaultDrafts = () => {
|
||||
const now = new Date().toISOString();
|
||||
return Array.from(DRAFT_NAMES).map((name) => {
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: cached ? cached.size : 0,
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: cached ? cached.updatedAt : now,
|
||||
created_at: cached ? cached.updatedAt : now,
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// If no client is connected, return defaults immediately without delaying listing
|
||||
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
|
||||
if (!hasClient) {
|
||||
return defaultDrafts();
|
||||
}
|
||||
|
||||
const requestId = `draft_list_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve(defaultDrafts());
|
||||
}, timeoutMs);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve: (filesOrList) => {
|
||||
if (Array.isArray(filesOrList)) {
|
||||
const now = new Date().toISOString();
|
||||
const normalized = filesOrList.map((item) => {
|
||||
const rawName = typeof item === 'string' ? item : (item.name || item.filename || '');
|
||||
const parsed = parseDraftPath(rawName.startsWith('drafts/') ? rawName : `drafts/${rawName}`);
|
||||
const nameKey = parsed.name || rawName;
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name: nameKey });
|
||||
return {
|
||||
name: `drafts/${nameKey}`,
|
||||
size: typeof item === 'object' && item.size != null ? item.size : (cached ? cached.size : 0),
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: typeof item === 'object' && item.updated_at ? item.updated_at : (cached ? cached.updatedAt : now),
|
||||
created_at: typeof item === 'object' && item.created_at ? item.created_at : (cached ? cached.updatedAt : now),
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
resolve(normalized);
|
||||
} else {
|
||||
resolve(defaultDrafts());
|
||||
}
|
||||
},
|
||||
reject: () => {
|
||||
resolve(defaultDrafts());
|
||||
},
|
||||
timer,
|
||||
companyId: effectiveCompanyId,
|
||||
userId,
|
||||
action: 'list',
|
||||
});
|
||||
|
||||
emit('draft:request', {
|
||||
companyId: effectiveCompanyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
requestId,
|
||||
action: 'list',
|
||||
sender: clientId || '',
|
||||
},
|
||||
});
|
||||
export function requestDraftList({ companyId, userId }) {
|
||||
const now = new Date().toISOString();
|
||||
return Array.from(DRAFT_NAMES).map((name) => {
|
||||
const cached = companyId ? getCachedDraft({ companyId, userId, name }) : null;
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: cached ? cached.size : 0,
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: cached ? cached.updatedAt : now,
|
||||
created_at: cached ? cached.updatedAt : now,
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle frontend response to a previous draft:request.
|
||||
*
|
||||
* @param {{ requestId: string, content?: any, error?: string, files?: any[] }} payload
|
||||
* @returns {{ handled: boolean, error?: string }}
|
||||
*/
|
||||
export function handleDraftResponse({ requestId, content, error, files }) {
|
||||
if (!requestId || !pendingRequests.has(requestId)) {
|
||||
return { handled: false, error: 'Unknown or expired requestId' };
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(requestId);
|
||||
pendingRequests.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
|
||||
if (error) {
|
||||
pending.reject(new Error(error));
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (pending.action === 'list') {
|
||||
pending.resolve(files !== undefined ? files : content);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
// action === 'read'
|
||||
const normalized = normalizeDraftContent(content);
|
||||
setCachedDraft({
|
||||
companyId: pending.companyId,
|
||||
userId: pending.userId,
|
||||
name: pending.name,
|
||||
content: normalized,
|
||||
});
|
||||
pending.resolve(normalized);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify frontend about a draft update via the normal /events SSE endpoint.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user