Compare commits
17 Commits
adb1cc81ef
...
b0c415f90f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c415f90f | ||
|
|
8a2225da7c | ||
|
|
e0c5971d20 | ||
|
|
a499d55636 | ||
|
|
c36890cc8b | ||
|
|
b80ba0434b | ||
|
|
01d3735dd1 | ||
|
|
e0bcb2fe0a | ||
|
|
a1c83a6134 | ||
|
|
bd5e3076ed | ||
|
|
316b8fa66a | ||
|
|
6f907f6a96 | ||
|
|
93caf0116d | ||
|
|
15af8d54e6 | ||
|
|
c4ed7b3482 | ||
|
|
066d407a5f | ||
|
|
c2826ae4be |
@@ -163,6 +163,16 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class QuotaError extends Error {
|
||||
constructor(
|
||||
public readonly reason: 'max_files' | 'monthly_tokens',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QuotaError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 stream listener types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -183,6 +193,12 @@ interface JourneyListener {
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface IndexSessionListener {
|
||||
onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
|
||||
onProgress: (frame: { processed: number; total: number }) => void;
|
||||
onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,6 +222,9 @@ export class BackendClient {
|
||||
/** Journey reply listeners keyed by sessionId. */
|
||||
private journeyListeners: Map<string, JourneyListener> = new Map();
|
||||
|
||||
/** Index session listeners keyed by sessionId. */
|
||||
private indexListeners: Map<string, IndexSessionListener> = new Map();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -564,6 +583,64 @@ export class BackendClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Index session WS methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a listener for an index session. Must be called before sending
|
||||
* the first `index_session_start` for that sessionId. Listener is auto-
|
||||
* removed when `index_session_done` is received.
|
||||
*/
|
||||
registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
|
||||
this.indexListeners.set(sessionId, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the opening `index_session_start` frame. Must be called after
|
||||
* `registerIndexSession`. Throws OfflineError if the WS is not connected.
|
||||
*/
|
||||
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one batch of files (typically up to 5).
|
||||
*/
|
||||
sendIndexFileBatch(
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
relPath: string;
|
||||
kind: 'text' | 'image' | 'pdf' | 'docx';
|
||||
content: string;
|
||||
ext?: string;
|
||||
mime?: string;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}>,
|
||||
): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight index session.
|
||||
*/
|
||||
sendIndexSessionCancel(sessionId: string): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
|
||||
const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP utilities
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -678,6 +755,72 @@ export class BackendClient {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Billing quota pre-flight
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-flight quota check for folder indexing.
|
||||
*
|
||||
* Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
|
||||
* estimated_files: estimatedFiles }`.
|
||||
*
|
||||
* Returns `{ ok: true }` when the backend allows the operation.
|
||||
* Throws `QuotaError` when the backend responds with HTTP 402.
|
||||
* Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
|
||||
*/
|
||||
async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const url = `${this.baseUrl}/api/v1/billing/quota/check`;
|
||||
const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
|
||||
logHttp('POST', url, bodyPayload);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(bodyPayload),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new OfflineError(err instanceof Error ? err.message : 'Network error');
|
||||
}
|
||||
|
||||
logHttpResponse('POST', url, res.status);
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
if (res.status === 402) {
|
||||
let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
|
||||
try {
|
||||
const body = await res.json() as { detail?: { reason: string; message: string } };
|
||||
if (body.detail?.reason && body.detail?.message) {
|
||||
detail = {
|
||||
reason: body.detail.reason as 'max_files' | 'monthly_tokens',
|
||||
message: body.detail.message,
|
||||
};
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
const reason = detail?.reason ?? 'max_files';
|
||||
const message = detail?.message ?? 'Quota exceeded';
|
||||
throw new QuotaError(reason, message);
|
||||
}
|
||||
|
||||
// Other error codes
|
||||
const text = await res.text().catch(() => '');
|
||||
const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
|
||||
if (res.status === 401) throw new AuthExpiredError(msg);
|
||||
if (res.status === 429) throw new RateLimitError(msg);
|
||||
if (res.status >= 500) throw new ServerError(msg, res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -861,6 +1004,32 @@ export class BackendClient {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_file_result': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onFileResult({
|
||||
relPath: frame.data.relPath,
|
||||
summary: frame.data.summary ?? null,
|
||||
tokensUsed: frame.data.tokensUsed,
|
||||
error: frame.data.error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_progress': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_done': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
if (lis) {
|
||||
lis.onDone(frame.data.status);
|
||||
this.indexListeners.delete(frame.data.sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -895,6 +1064,8 @@ export class BackendClient {
|
||||
}
|
||||
this.journeyListeners.clear();
|
||||
}
|
||||
// Index session listeners are fire-and-forget; just drop them on disconnect.
|
||||
this.indexListeners.clear();
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments } from '../db/schema';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,6 +31,7 @@ const TABLE_REGISTRY = {
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
projectFolderFiles,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
@@ -187,6 +188,12 @@ export class DrizzleExecutor {
|
||||
return this.handleReadFileContent(payload);
|
||||
case 'get_file_metadata':
|
||||
return this.handleGetFileMetadata(payload);
|
||||
case 'read_project_folder_manifest':
|
||||
return this.handleReadProjectFolderManifest(payload);
|
||||
case 'read_project_folder_file':
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
@@ -436,4 +443,129 @@ export class DrizzleExecutor {
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Project folder handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
|
||||
const { projectId } = (payload.data ?? {}) as { projectId: string };
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
|
||||
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
|
||||
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
|
||||
// for the next call.
|
||||
if (proj.folderLastScanStatus !== 'scanning') {
|
||||
void import('../files/scanner')
|
||||
.then(async ({ scanFolder }) => {
|
||||
const delta = await scanFolder(projectId, proj.folderPath!);
|
||||
if (
|
||||
delta.newFiles.length > 0 ||
|
||||
delta.changedFiles.length > 0 ||
|
||||
delta.deletedRelPaths.length > 0
|
||||
) {
|
||||
const { startIndexSession } = await import('../files/indexer');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(projectId, () => {});
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
folderPath: proj.folderPath,
|
||||
lastScannedAt: proj.folderLastScannedAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { projectId, relativePath } = (payload.data ?? {}) as {
|
||||
projectId: string;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
// Re-check guards even though backend tool also guards
|
||||
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { content: '' };
|
||||
|
||||
const abs = path.join(proj.folderPath, relativePath);
|
||||
// Confine to folderPath
|
||||
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
if (stat.size > MAX_READ_SIZE_BYTES) {
|
||||
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
|
||||
const fd = await fs.promises.open(abs, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return { content: buf.toString('utf8') + '\n[…truncated]' };
|
||||
}
|
||||
|
||||
return { content: await fs.promises.readFile(abs, 'utf-8') };
|
||||
} catch {
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`${projects.folderPath} IS NOT NULL`)
|
||||
.all();
|
||||
|
||||
const out: Array<unknown> = [];
|
||||
for (const p of projs) {
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, p.id))
|
||||
.all();
|
||||
out.push({
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderPath: p.folderPath,
|
||||
lastScannedAt: p.folderLastScannedAt,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return { projects: out };
|
||||
}
|
||||
}
|
||||
|
||||
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_folder_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`relative_path` text NOT NULL,
|
||||
`ext` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`summary` text,
|
||||
`summary_updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;
|
||||
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
|
||||
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"project_folder_files": {
|
||||
"name": "project_folder_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ext": {
|
||||
"name": "ext",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary_updated_at": {
|
||||
"name": "summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_path": {
|
||||
"name": "folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scanned_at": {
|
||||
"name": "folder_last_scanned_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scan_status": {
|
||||
"name": "folder_last_scan_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"folder_total_files": {
|
||||
"name": "folder_total_files",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1778238659431,
|
||||
"tag": "0004_right_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
|
||||
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
folderPath: text('folder_path'),
|
||||
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
|
||||
folderLastScanStatus: text('folder_last_scan_status', {
|
||||
enum: ['idle', 'scanning', 'error'],
|
||||
}).default('idle'),
|
||||
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
@@ -64,6 +70,21 @@ export const notes = sqliteTable('notes', {
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectFolderFiles = sqliteTable('project_folder_files', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
relativePath: text('relative_path').notNull(),
|
||||
ext: text('ext').notNull(),
|
||||
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
|
||||
summary: text('summary'),
|
||||
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
|
||||
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
|
||||
|
||||
export const noteEdits = sqliteTable('note_edits', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull(),
|
||||
|
||||
21
src/main/files/constants.ts
Normal file
21
src/main/files/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** File-type whitelists & size caps for project folder indexing. */
|
||||
|
||||
export const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.rst', '.adoc',
|
||||
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
|
||||
'.html', '.htm', '.xml',
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
||||
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
|
||||
'.css', '.scss', '.sass',
|
||||
'.sql',
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
|
||||
export const PDF_EXTS = new Set(['.pdf']);
|
||||
export const DOCX_EXTS = new Set(['.docx']);
|
||||
|
||||
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const INDEX_BATCH_SIZE = 5;
|
||||
27
src/main/files/daily-rescan.ts
Normal file
27
src/main/files/daily-rescan.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// adiuvAI/src/main/files/daily-rescan.ts
|
||||
import { getDb } from '../db';
|
||||
import { projects } from '../db/schema';
|
||||
import { sql, and, isNotNull } from 'drizzle-orm';
|
||||
import { startIndexSession } from './indexer';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function runDailyRescan(): Promise<void> {
|
||||
const cutoff = Date.now() - ONE_DAY_MS;
|
||||
const stale = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(projects.folderPath),
|
||||
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const p of stale) {
|
||||
if (p.folderLastScanStatus === 'scanning') continue;
|
||||
// Fire-and-forget; no UI listener.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(p.id, () => {});
|
||||
}
|
||||
}
|
||||
222
src/main/files/indexer.ts
Normal file
222
src/main/files/indexer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Folder index session orchestrator.
|
||||
*
|
||||
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
|
||||
* returned summaries to projectFolderFiles, drives progress callbacks.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { scanFolder, type ScannedFile } from './scanner';
|
||||
import { INDEX_BATCH_SIZE } from './constants';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
export interface IndexProgress {
|
||||
sessionId: string;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ProgressListener = (p: IndexProgress) => void;
|
||||
|
||||
async function readForIndex(
|
||||
folderPath: string,
|
||||
f: ScannedFile,
|
||||
): Promise<{ content: string; mime?: string }> {
|
||||
const abs = path.join(folderPath, f.relativePath);
|
||||
if (f.kind === 'image') {
|
||||
const buf = await readFile(abs);
|
||||
const ext = f.ext.toLowerCase();
|
||||
const mime =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
return { content: buf.toString('base64'), mime };
|
||||
}
|
||||
if (f.kind === 'text') {
|
||||
return { content: await readFile(abs, 'utf-8') };
|
||||
}
|
||||
// pdf / docx: read as binary, base64. Server is responsible for extraction.
|
||||
const buf = await readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
export async function startIndexSession(
|
||||
projectId: string,
|
||||
onProgress: ProgressListener,
|
||||
): Promise<{ sessionId: string; cancel: () => void }> {
|
||||
const sessionId = randomUUID();
|
||||
const db = getDb();
|
||||
|
||||
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj || !proj.folderPath) {
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
db.update(projects)
|
||||
.set({ folderLastScanStatus: 'scanning' })
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
|
||||
|
||||
const delta = await scanFolder(projectId, proj.folderPath);
|
||||
|
||||
// Filter out 'skipped' files — they are too large to index and must not be sent
|
||||
const toIndex = [
|
||||
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
|
||||
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
|
||||
];
|
||||
const total = toIndex.length;
|
||||
|
||||
for (const rel of delta.deletedRelPaths) {
|
||||
db.delete(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, rel),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus: 'idle',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
const backend = getBackendClient();
|
||||
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finalize = (status: IndexProgress['status'], error?: string): void => {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus:
|
||||
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount + processed,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed, total, status, error });
|
||||
};
|
||||
|
||||
backend.registerIndexSession(sessionId, {
|
||||
onFileResult: ({ relPath, summary, error }) => {
|
||||
if (error) return;
|
||||
const f = toIndex.find((x) => x.relativePath === relPath);
|
||||
if (!f) return;
|
||||
const now = Date.now();
|
||||
|
||||
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, f.relativePath),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
db.update(projectFolderFiles)
|
||||
.set({
|
||||
mtimeMs: f.mtimeMs,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.where(eq(projectFolderFiles.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(projectFolderFiles)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
relativePath: f.relativePath,
|
||||
ext: f.ext,
|
||||
kind: f.kind,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
onProgress: ({ processed: p, total: t }) => {
|
||||
processed = p;
|
||||
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
|
||||
},
|
||||
onDone: (status) => {
|
||||
finalize(
|
||||
status === 'completed'
|
||||
? 'completed'
|
||||
: status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: status === 'quota_exceeded'
|
||||
? 'quota_exceeded'
|
||||
: 'error',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
backend.sendIndexSessionStart(sessionId, projectId, total);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
// Send batches (skipped files already excluded from toIndex)
|
||||
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
|
||||
const payload = await Promise.all(
|
||||
batch.map(async (f) => {
|
||||
const { content, mime } = await readForIndex(proj.folderPath!, f);
|
||||
return {
|
||||
relPath: f.relativePath,
|
||||
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
|
||||
content,
|
||||
ext: f.ext,
|
||||
mime,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
try {
|
||||
backend.sendIndexFileBatch(sessionId, payload);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
cancelled = true;
|
||||
backend.sendIndexSessionCancel(sessionId);
|
||||
};
|
||||
return { sessionId, cancel };
|
||||
}
|
||||
95
src/main/files/scanner.ts
Normal file
95
src/main/files/scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getDb } from '../db';
|
||||
import { projectFolderFiles } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
|
||||
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
|
||||
} from './constants';
|
||||
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
|
||||
|
||||
export interface ScannedFile {
|
||||
relativePath: string;
|
||||
ext: string;
|
||||
kind: FileKind;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScanDelta {
|
||||
newFiles: ScannedFile[];
|
||||
changedFiles: ScannedFile[];
|
||||
unchangedCount: number;
|
||||
deletedRelPaths: string[];
|
||||
}
|
||||
|
||||
function classify(ext: string, sizeBytes: number): FileKind | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
|
||||
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
|
||||
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
|
||||
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
|
||||
return null; // not indexable
|
||||
}
|
||||
|
||||
async function walk(root: string): Promise<ScannedFile[]> {
|
||||
const out: ScannedFile[] = [];
|
||||
async function recurse(dir: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied — skip silently
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue; // skip dot dirs / files
|
||||
if (e.name === 'node_modules') continue; // common noise
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await recurse(full);
|
||||
} else if (e.isFile()) {
|
||||
let s;
|
||||
try { s = await stat(full); } catch { continue; }
|
||||
const ext = path.extname(e.name);
|
||||
const kind = classify(ext, s.size);
|
||||
if (kind === null) continue;
|
||||
out.push({
|
||||
relativePath: path.relative(root, full),
|
||||
ext,
|
||||
kind,
|
||||
sizeBytes: s.size,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await recurse(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
|
||||
const scanned = await walk(folderPath);
|
||||
const existing = getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
|
||||
const newFiles: ScannedFile[] = [];
|
||||
const changedFiles: ScannedFile[] = [];
|
||||
let unchanged = 0;
|
||||
for (const f of scanned) {
|
||||
const prev = existingMap.get(f.relativePath);
|
||||
if (!prev) newFiles.push(f);
|
||||
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
|
||||
else unchanged++;
|
||||
existingMap.delete(f.relativePath);
|
||||
}
|
||||
const deletedRelPaths = Array.from(existingMap.keys());
|
||||
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
|
||||
import { backfillNoteSummaries } from './db/notes-backfill';
|
||||
import { runDailyRescan } from './files/daily-rescan';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -134,6 +135,8 @@ app.on('ready', () => {
|
||||
|
||||
startBriefScheduler();
|
||||
startAgentScheduler();
|
||||
// Delay so WS connection is likely up before triggering rescans
|
||||
setTimeout(() => { void runDailyRescan(); }, 10_000);
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
|
||||
@@ -17,6 +17,7 @@ import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBr
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
import { projectFoldersRouter } from './projectFolders';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
@@ -1854,6 +1855,7 @@ export const appRouter = router({
|
||||
auth: authRouter,
|
||||
agent: agentRouter,
|
||||
memory: memoryRouter,
|
||||
projectFolders: projectFoldersRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
128
src/main/router/projectFolders.ts
Normal file
128
src/main/router/projectFolders.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// adiuvAI/src/main/router/projectFolders.ts
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { dialog } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { startIndexSession, type IndexProgress } from '../files/indexer';
|
||||
import { scanFolder } from '../files/scanner';
|
||||
import { getBackendClient, QuotaError } from '../api/backend-client';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
// In-memory map of active sessions per projectId so we can cancel
|
||||
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
|
||||
|
||||
export const projectFoldersRouter = router({
|
||||
chooseFolder: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
link: publicProcedure
|
||||
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.update(projects)
|
||||
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
unlink: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderPath: null,
|
||||
folderLastScannedAt: null,
|
||||
folderLastScanStatus: 'idle',
|
||||
folderTotalFiles: 0,
|
||||
})
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
startScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
|
||||
if (!proj?.folderPath) throw new Error('No folder linked');
|
||||
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
|
||||
|
||||
// Pre-flight: walk folder to estimate indexable file count, then ask the
|
||||
// backend whether the user's tier allows proceeding.
|
||||
const delta = await scanFolder(input.projectId, proj.folderPath);
|
||||
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
|
||||
|
||||
try {
|
||||
await getBackendClient().checkFolderQuota(estimated);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaError) {
|
||||
// Encode reason + backend message so the renderer can produce a
|
||||
// localised toast without an extra RPC call.
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: `QUOTA:${err.reason}:${err.message}`,
|
||||
});
|
||||
}
|
||||
// Network / auth errors: propagate as-is so the renderer shows a
|
||||
// generic error toast rather than silently swallowing the problem.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await startIndexSession(input.projectId, (p) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.lastProgress = p;
|
||||
if (
|
||||
p.status === 'completed' ||
|
||||
p.status === 'cancelled' ||
|
||||
p.status === 'quota_exceeded' ||
|
||||
p.status === 'error'
|
||||
) {
|
||||
_active.delete(input.projectId);
|
||||
}
|
||||
});
|
||||
_active.set(input.projectId, {
|
||||
cancel: session.cancel,
|
||||
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
|
||||
});
|
||||
return { sessionId: session.sessionId };
|
||||
}),
|
||||
|
||||
cancelScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.cancel();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getStatus: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
return entry?.lastProgress ?? null;
|
||||
}),
|
||||
|
||||
listFiles: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, input.projectId))
|
||||
.orderBy(projectFolderFiles.relativePath)
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import { useTimelineHistory } from '@/hooks/useTimelineHistory';
|
||||
import type { EventSnapshot } from '@/components/timeline/history-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
|
||||
import { FolderChip } from './folder/FolderChip';
|
||||
import { FilesSection } from './folder/FilesSection';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -46,11 +48,13 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const tasksRef = useRef<HTMLDivElement>(null);
|
||||
const notesRef = useRef<HTMLDivElement>(null);
|
||||
const filesRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
|
||||
overview: summaryRef,
|
||||
timeline: timelineRef,
|
||||
tasks: tasksRef,
|
||||
notes: notesRef,
|
||||
files: filesRef,
|
||||
}), []);
|
||||
|
||||
const didInitialScroll = useRef(false);
|
||||
@@ -58,6 +62,11 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
|
||||
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
|
||||
{ projectId },
|
||||
{ refetchInterval: (query) => query.state.data?.status === 'scanning' ? 1000 : false },
|
||||
);
|
||||
|
||||
const {
|
||||
historyOpRef,
|
||||
pendingCreatePayloadRef,
|
||||
@@ -457,6 +466,38 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200 ease-out',
|
||||
compact ? 'max-h-0 mt-0 opacity-0' : 'max-h-8 mt-2 opacity-100',
|
||||
)}
|
||||
>
|
||||
<FolderChip
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
scanProgress={scanStatus && scanStatus.status === 'scanning'
|
||||
? { processed: scanStatus.processed, total: scanStatus.total }
|
||||
: null}
|
||||
onClick={() => {
|
||||
const el = scrollRef.current;
|
||||
const ref = filesRef.current;
|
||||
if (el && ref) {
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const sectionTop = ref.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: 'files' }),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -614,6 +655,17 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Files section */}
|
||||
<div ref={filesRef} data-section="files" className="mx-auto max-w-6xl px-8 py-10 border-t border-border/40">
|
||||
<FilesSection
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
|
||||
export type SectionId = typeof SECTIONS[number];
|
||||
|
||||
interface ProjectTabBarProps {
|
||||
@@ -83,6 +83,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
timeline: t('projects.projectTimeline'),
|
||||
tasks: t('projects.tasks'),
|
||||
notes: t('projects.notes'),
|
||||
files: t('projects.folder.title'),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addMonths, startOfMonth, format } from 'date-fns';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
} from '@/components/ui/empty';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { usePlatform } from '@/lib/platform';
|
||||
import { FolderLinkCard } from './FolderLinkCard';
|
||||
import { FolderFileList } from './FolderFileList';
|
||||
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
|
||||
|
||||
interface FilesSectionProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
}
|
||||
|
||||
export function FilesSection({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
}: FilesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const platform = usePlatform();
|
||||
const [unlinkOpen, setUnlinkOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
|
||||
const link = trpc.projectFolders.link.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
const startScan = trpc.projectFolders.startScan.useMutation();
|
||||
|
||||
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
|
||||
function handleScanError(err: { message?: string }): void {
|
||||
const msg = err.message ?? '';
|
||||
if (msg.startsWith('QUOTA:max_files:')) {
|
||||
// Backend message format: "Folder has X files; tier 'free' allows max Y."
|
||||
// Extract tier and max-count to pass to the i18n key.
|
||||
const detail = msg.slice('QUOTA:max_files:'.length);
|
||||
const tierMatch = detail.match(/tier '([^']+)'/);
|
||||
const countMatch = detail.match(/allows max (\d+)/);
|
||||
const tier = tierMatch?.[1] ?? 'your';
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
|
||||
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
|
||||
return;
|
||||
}
|
||||
if (msg.startsWith('QUOTA:monthly_tokens:')) {
|
||||
// Compute first day of next month as the reset date.
|
||||
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
|
||||
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
|
||||
return;
|
||||
}
|
||||
notifyError('errors.error', err);
|
||||
}
|
||||
|
||||
const handleChoose = async () => {
|
||||
const chosen = await chooseFolder.mutateAsync();
|
||||
if (chosen) {
|
||||
await link.mutateAsync({ projectId, folderPath: chosen });
|
||||
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
|
||||
// Quota errors are caught here so we can show localised toasts.
|
||||
startScan.mutate({ projectId }, { onError: handleScanError });
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform.isElectron) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground p-6 text-center">
|
||||
{t('projects.folder.webOnlyTooltip')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<Sparkles />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
|
||||
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
onClick={handleChoose}
|
||||
disabled={chooseFolder.isPending || link.isPending}
|
||||
>
|
||||
{t('projects.folder.empty.cta')}
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FolderLinkCard
|
||||
projectId={projectId}
|
||||
folderPath={folderPath}
|
||||
totalFiles={totalFiles}
|
||||
lastScannedAt={lastScannedAt}
|
||||
scanStatus={scanStatus}
|
||||
onUnlinkRequested={() => setUnlinkOpen(true)}
|
||||
/>
|
||||
<FolderFileList projectId={projectId} />
|
||||
<FolderUnlinkDialog
|
||||
projectId={projectId}
|
||||
open={unlinkOpen}
|
||||
onOpenChange={setUnlinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderChipProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
scanProgress?: { processed: number; total: number } | null;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
scanProgress,
|
||||
onClick,
|
||||
}: FolderChipProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('projects.folder.linkCta')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'scanning' && scanProgress) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<Folder className="h-3 w-3 animate-pulse" />
|
||||
{t('projects.folder.scanning', {
|
||||
processed: scanProgress.processed,
|
||||
total: scanProgress.total,
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('projects.folder.scanFailed')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const relative = lastScannedAt
|
||||
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
|
||||
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="opacity-70">{relative}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FolderFileListProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
|
||||
|
||||
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
|
||||
|
||||
export function FolderFileList({ projectId }: FolderFileListProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === 'all') return data;
|
||||
return data.filter((f) => f.kind === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 text-xs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-full border border-border',
|
||||
filter === f
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 border border-border bg-background/50',
|
||||
f.kind === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-xs">{f.relativePath}</div>
|
||||
{f.summary && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
68
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderLinkCardProps {
|
||||
projectId: string;
|
||||
folderPath: string;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
onUnlinkRequested: () => void;
|
||||
}
|
||||
|
||||
export function FolderLinkCard({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
onUnlinkRequested,
|
||||
}: FolderLinkCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const startScan = trpc.projectFolders.startScan.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-xl p-4 flex items-start gap-3 bg-background">
|
||||
<div className="h-10 w-10 rounded-lg bg-[#fbc881]/30 grid place-items-center shrink-0">
|
||||
<Folder className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{t('projects.folder.title')}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('projects.folder.filesCount', { count: totalFiles })}
|
||||
{lastScannedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('projects.folder.lastScanned', {
|
||||
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={scanStatus === 'scanning' || startScan.isPending}
|
||||
onClick={() => startScan.mutate({ projectId })}
|
||||
>
|
||||
{t('projects.folder.rescan')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
|
||||
{t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
|
||||
interface FolderUnlinkDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FolderUnlinkDialog({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FolderUnlinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const unlink = trpc.projectFolders.unlink.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.projects.get.invalidate({ id: projectId });
|
||||
utils.projectFolders.listFiles.invalidate({ projectId });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projects.deleteProjectDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => unlink.mutate({ projectId })}
|
||||
disabled={unlink.isPending}
|
||||
>
|
||||
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -373,7 +373,30 @@
|
||||
"untitledNote": "Unbenannte Notiz",
|
||||
"noNotesYet": "Noch keine Notizen.",
|
||||
"noNotesYetDescription": "Klicke auf \"+ Hinzufügen\", um deine erste Notiz zu erstellen.",
|
||||
"overview": "Übersicht"
|
||||
"overview": "Übersicht",
|
||||
"folder": {
|
||||
"title": "Dateien",
|
||||
"linkCta": "Ordner verknüpfen",
|
||||
"browse": "Durchsuchen",
|
||||
"rescan": "Neu scannen",
|
||||
"unlink": "Verknüpfung aufheben",
|
||||
"filesCount_one": "{{count}} Datei",
|
||||
"filesCount_other": "{{count}} Dateien",
|
||||
"lastScanned": "zuletzt gescannt {{relative}}",
|
||||
"scanning": "Indizierung {{processed}}/{{total}}",
|
||||
"scanFailed": "Scan fehlgeschlagen",
|
||||
"empty": {
|
||||
"title": "Einen Projektordner verknüpfen",
|
||||
"description": "Verbinden Sie einen lokalen Ordner, damit KI-Agenten dessen Dateien lesen können, wenn sie Fragen zu diesem Projekt beantworten.",
|
||||
"cta": "Ordner auswählen…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Ordner zu groß für den {{tier}}-Plan — max. {{count}} Dateien",
|
||||
"monthlyExhausted": "Monatliches Token-Budget erschöpft (zurückgesetzt am {{date}})",
|
||||
"notFound": "Ordner nicht gefunden: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Ordnerverknüpfung ist in der Desktop-App verfügbar"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agenten",
|
||||
|
||||
@@ -373,7 +373,30 @@
|
||||
"untitledNote": "Untitled Note",
|
||||
"noNotesYet": "No notes yet.",
|
||||
"noNotesYetDescription": "Click \"+ Add\" to create your first note.",
|
||||
"overview": "Overview"
|
||||
"overview": "Overview",
|
||||
"folder": {
|
||||
"title": "Files",
|
||||
"linkCta": "Link folder",
|
||||
"browse": "Browse",
|
||||
"rescan": "Rescan",
|
||||
"unlink": "Unlink",
|
||||
"filesCount_one": "{{count}} file",
|
||||
"filesCount_other": "{{count}} files",
|
||||
"lastScanned": "last scanned {{relative}}",
|
||||
"scanning": "indexing {{processed}}/{{total}}",
|
||||
"scanFailed": "Scan failed",
|
||||
"empty": {
|
||||
"title": "Link a project folder",
|
||||
"description": "Connect a local folder so AI agents can read its files when answering questions about this project.",
|
||||
"cta": "Choose folder…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Folder too big for {{tier}} plan — max {{count}} files",
|
||||
"monthlyExhausted": "Monthly token budget exhausted (resets {{date}})",
|
||||
"notFound": "Folder not found: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Folder linking available in desktop app"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
|
||||
@@ -373,7 +373,30 @@
|
||||
"untitledNote": "Nota sin título",
|
||||
"noNotesYet": "No hay notas.",
|
||||
"noNotesYetDescription": "Haz clic en \"+ Añadir\" para crear tu primera nota.",
|
||||
"overview": "Resumen"
|
||||
"overview": "Resumen",
|
||||
"folder": {
|
||||
"title": "Archivos",
|
||||
"linkCta": "Vincular carpeta",
|
||||
"browse": "Explorar",
|
||||
"rescan": "Reescanear",
|
||||
"unlink": "Desvincular",
|
||||
"filesCount_one": "{{count}} archivo",
|
||||
"filesCount_other": "{{count}} archivos",
|
||||
"lastScanned": "último escaneo {{relative}}",
|
||||
"scanning": "indexando {{processed}}/{{total}}",
|
||||
"scanFailed": "Error de escaneo",
|
||||
"empty": {
|
||||
"title": "Vincula una carpeta al proyecto",
|
||||
"description": "Conecta una carpeta local para que los agentes de IA puedan leer sus archivos al responder preguntas sobre este proyecto.",
|
||||
"cta": "Elegir carpeta…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Carpeta demasiado grande para el plan {{tier}} — máx. {{count}} archivos",
|
||||
"monthlyExhausted": "Presupuesto mensual de tokens agotado (se renueva el {{date}})",
|
||||
"notFound": "Carpeta no encontrada: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "La vinculación de carpetas está disponible en la app de escritorio"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agentes",
|
||||
|
||||
@@ -373,7 +373,30 @@
|
||||
"untitledNote": "Note sans titre",
|
||||
"noNotesYet": "Aucune note.",
|
||||
"noNotesYetDescription": "Cliquez sur \"+ Ajouter\" pour créer votre première note.",
|
||||
"overview": "Vue d'ensemble"
|
||||
"overview": "Vue d'ensemble",
|
||||
"folder": {
|
||||
"title": "Fichiers",
|
||||
"linkCta": "Lier un dossier",
|
||||
"browse": "Parcourir",
|
||||
"rescan": "Rescanner",
|
||||
"unlink": "Dissocier",
|
||||
"filesCount_one": "{{count}} fichier",
|
||||
"filesCount_other": "{{count}} fichiers",
|
||||
"lastScanned": "dernier scan {{relative}}",
|
||||
"scanning": "indexation {{processed}}/{{total}}",
|
||||
"scanFailed": "Échec du scan",
|
||||
"empty": {
|
||||
"title": "Lier un dossier au projet",
|
||||
"description": "Connectez un dossier local pour que les agents IA puissent lire ses fichiers lorsqu'ils répondent aux questions sur ce projet.",
|
||||
"cta": "Choisir un dossier…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Dossier trop grand pour le plan {{tier}} — max {{count}} fichiers",
|
||||
"monthlyExhausted": "Budget mensuel de tokens épuisé (réinitialisé le {{date}})",
|
||||
"notFound": "Dossier introuvable : {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "La liaison de dossiers est disponible dans l'application bureau"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
|
||||
@@ -373,7 +373,30 @@
|
||||
"untitledNote": "Nota senza titolo",
|
||||
"noNotesYet": "Nessuna nota.",
|
||||
"noNotesYetDescription": "Clicca \"+ Aggiungi\" per creare la tua prima nota.",
|
||||
"overview": "Panoramica"
|
||||
"overview": "Panoramica",
|
||||
"folder": {
|
||||
"title": "File",
|
||||
"linkCta": "Collega cartella",
|
||||
"browse": "Sfoglia",
|
||||
"rescan": "Riscansiona",
|
||||
"unlink": "Scollega",
|
||||
"filesCount_one": "{{count}} file",
|
||||
"filesCount_other": "{{count}} file",
|
||||
"lastScanned": "ultima scansione {{relative}}",
|
||||
"scanning": "indicizzazione {{processed}}/{{total}}",
|
||||
"scanFailed": "Scansione fallita",
|
||||
"empty": {
|
||||
"title": "Collega una cartella al progetto",
|
||||
"description": "Connetti una cartella locale in modo che gli agenti AI possano leggerne i file quando rispondono alle domande su questo progetto.",
|
||||
"cta": "Scegli cartella…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Cartella troppo grande per il piano {{tier}} — max {{count}} file",
|
||||
"monthlyExhausted": "Budget mensile token esaurito (si rinnova il {{date}})",
|
||||
"notFound": "Cartella non trovata: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Il collegamento cartelle è disponibile nell'app desktop"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agenti",
|
||||
|
||||
@@ -278,6 +278,31 @@ export const WsRunCompleteSchema = z.object({
|
||||
});
|
||||
export type WsRunComplete = z.infer<typeof WsRunCompleteSchema>;
|
||||
|
||||
export const WsIndexFileResultSchema = z.object({
|
||||
type: z.literal('index_file_result'),
|
||||
sessionId: z.string(),
|
||||
relPath: z.string(),
|
||||
summary: z.string().nullable().optional(),
|
||||
tokensUsed: z.number().int().nonnegative().default(0),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type WsIndexFileResult = z.infer<typeof WsIndexFileResultSchema>;
|
||||
|
||||
export const WsIndexSessionProgressSchema = z.object({
|
||||
type: z.literal('index_session_progress'),
|
||||
sessionId: z.string(),
|
||||
processed: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
});
|
||||
export type WsIndexSessionProgress = z.infer<typeof WsIndexSessionProgressSchema>;
|
||||
|
||||
export const WsIndexSessionDoneSchema = z.object({
|
||||
type: z.literal('index_session_done'),
|
||||
sessionId: z.string(),
|
||||
status: z.enum(['completed', 'cancelled', 'quota_exceeded', 'error']),
|
||||
});
|
||||
export type WsIndexSessionDone = z.infer<typeof WsIndexSessionDoneSchema>;
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsToolCallSchema,
|
||||
WsPingSchema,
|
||||
@@ -287,6 +312,9 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsFloatingDomainSchema,
|
||||
WsJourneyReplySchema,
|
||||
WsRunCompleteSchema,
|
||||
WsIndexFileResultSchema,
|
||||
WsIndexSessionProgressSchema,
|
||||
WsIndexSessionDoneSchema,
|
||||
]);
|
||||
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user