This commit is contained in:
seb
2026-10-16 16:24:58 +02:00
parent f506dd35be
commit 3b875c9f9a
14 changed files with 28 additions and 1228 deletions

View File

@@ -17,7 +17,6 @@ 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';
@@ -503,38 +502,6 @@ 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;
@@ -773,8 +740,8 @@ export class ConversationManager {
// can never end up pointed at different companies.
createJavascriptExecuteTool({ googleAuthId }),
createFindFilesTool({ googleAuthId }),
createReadFileTool({ googleAuthId, conversationId: this.conversationId }),
createPatchTool({ googleAuthId, conversationId: this.conversationId }),
createReadFileTool({ googleAuthId }),
createPatchTool({ googleAuthId }),
createRmTool({ googleAuthId }),
createSearchImagesTool({ googleAuthId }),
createAnalyzeImageTool({ googleAuthId, model }),

View File

@@ -1,235 +0,0 @@
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']);
/**
* Checks and parses a VFS path to determine if it points to the drafts directory or a draft file.
* Accepts paths like:
* - 'drafts', '/drafts', 'drafts/'
* - 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'vfs://drafts/delivery'
*
* @param {string} filePath
* @returns {{ isFolder: boolean, isDraft: boolean, name?: 'quote'|'order'|'delivery', filename?: string }}
*/
export function parseDraftPath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) {
return { isFolder: false, isDraft: false };
}
let clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
clean = clean.replace(/^api\//i, '').replace(/^vfs\//i, '');
const segments = clean.split('/').filter(Boolean);
if (segments.length === 0) return { isFolder: false, isDraft: false };
if (segments[0].toLowerCase() === 'drafts') {
if (segments.length === 1) {
return { isFolder: true, isDraft: false };
}
if (segments.length === 2) {
const rawName = segments[1];
const lower = rawName.toLowerCase();
const withoutExt = lower.endsWith('.json') ? lower.slice(0, -5) : lower;
if (DRAFT_NAMES.has(withoutExt)) {
return { isFolder: false, isDraft: true, name: withoutExt, filename: rawName };
}
}
}
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 }
*/
const draftCache = new Map();
function cacheKey(companyId, userId, name) {
return `${companyId}:${userId}:${name}`;
}
export function getCachedDraft({ companyId, userId, name }) {
return draftCache.get(cacheKey(companyId, userId, name)) || null;
}
/**
* Normalizes draft content by deduplicating redundant `items` array when `lines` exists.
* @param {any} content
* @returns {any}
*/
export function normalizeDraftContent(content) {
if (!content) return content;
let parsed = content;
let wasString = false;
if (typeof content === 'string') {
try {
parsed = JSON.parse(content);
wasString = true;
} catch {
return content;
}
}
if (parsed && typeof parsed === 'object') {
const target = parsed.manifest && typeof parsed.manifest === 'object' ? parsed.manifest : parsed;
if (target && typeof target === 'object') {
if (Array.isArray(target.lines) && Array.isArray(target.items)) {
delete target.items;
} else if (!Array.isArray(target.lines) && Array.isArray(target.items)) {
target.lines = target.items;
delete target.items;
}
}
}
return wasString ? JSON.stringify(parsed, null, 2) : parsed;
}
export function setCachedDraft({ companyId, userId, name, content }) {
const normalized = normalizeDraftContent(content);
const updatedAt = new Date().toISOString();
const str = typeof normalized === 'string' ? normalized : JSON.stringify(normalized ?? {}, null, 2);
const entry = {
content: normalized,
updatedAt,
size: Buffer.byteLength(str, 'utf8'),
};
draftCache.set(cacheKey(companyId, userId, name), entry);
return entry;
}
/**
* Resolves the selected company ID for a given googleAuth ID if needed.
* @param {number} googleAuthId
* @returns {Promise<number|null>}
*/
export async function resolveCompanyForUser(googleAuthId) {
if (!googleAuthId) return null;
try {
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
return row?.selectedCompanyId ?? null;
} catch {
return null;
}
}
/**
* Request a draft content.
* Stateless model:
* - If cached, returns immediately.
* - In conversation: raises DirectReturnError(..., { recordResult: false }) so the assistant
* tool call remains the last message in `modelmessage` until answered (exactly like ask_user).
* - Outside conversation: returns default empty draft envelope.
*
* @param {{ companyId?: number, userId: number, conversationId?: number, name: string }} options
* @returns {Promise<any>}
*/
export async function requestDraftRead({ companyId, userId, conversationId, name }) {
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
if (effectiveCompanyId == null) {
throw new Error('No company selected');
}
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
if (cached) {
return cached.content;
}
if (conversationId) {
// Halt turn statelessly; assistant tool call stays as the last message in modelmessage, like ask_user
throw new DirectReturnError({
action: 'draft_read',
draft: name,
conversationId,
}, { recordResult: false });
}
return defaultDraftContent(name);
}
/**
* 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 }} options
* @returns {Array<object>}
*/
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,
};
});
}
/**
* Notify frontend about a draft update via the normal /events SSE endpoint.
*
* @param {{ companyId: number, userId: number, clientId?: string, name: string, content: any }} options
* @returns {{ name: string, size: number, updatedAt: string }}
*/
export function notifyDraftUpdate({ companyId, userId, clientId, name, content }) {
const entry = setCachedDraft({ companyId, userId, name, content });
emit('draft', {
companyId,
targetUserId: userId,
targetClientId: clientId,
data: {
sender: clientId || '',
action: 'update',
name,
content,
},
});
return {
name: `drafts/${name}`,
size: entry.size,
updatedAt: entry.updatedAt,
};
}
/**
* Synchronous/fire-and-forget version of notifyDraftUpdate for sync writes.
*
* @param {{ googleAuthId: number, clientId?: string, name: string, content: any }} options
*/
export function notifyDraftUpdateSync({ googleAuthId, clientId, name, content }) {
resolveCompanyForUser(googleAuthId).then((companyId) => {
if (companyId != null) {
notifyDraftUpdate({ companyId, userId: googleAuthId, clientId, name, content });
}
}).catch((err) => {
console.error('[draftManager] notifyDraftUpdateSync error:', err);
});
}