fraim-hub 2.0.315 → 2.0.316
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/atomic-json-file.js +16 -1
- package/dist/src/ai-hub/attachment-store.js +137 -0
- package/dist/src/ai-hub/conversation-store.js +28 -0
- package/dist/src/ai-hub/hosts.js +4 -1
- package/dist/src/ai-hub/server.js +132 -7
- package/package.json +3 -2
- package/public/ai-hub/index.html +11 -2
- package/public/ai-hub/script.js +285 -9
- package/public/ai-hub/styles.css +119 -1
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.renameWithRetry = renameWithRetry;
|
|
7
7
|
exports.writeJsonAtomic = writeJsonAtomic;
|
|
8
|
+
exports.writeFileAtomic = writeFileAtomic;
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
10
11
|
// Atomic JSON file write for the conversation store and its derived caches.
|
|
@@ -47,9 +48,23 @@ function renameWithRetry(from, to) {
|
|
|
47
48
|
* writing the same destination cannot collide on the temp file itself.
|
|
48
49
|
*/
|
|
49
50
|
function writeJsonAtomic(filePath, value) {
|
|
51
|
+
writeFileAtomic(filePath, JSON.stringify(value), 'utf8');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Write-temp-then-rename for arbitrary content (binary or text) — the same durability guarantee
|
|
55
|
+
* as `writeJsonAtomic` without the JSON serialization step. Issue #1651: attachment blobs need the
|
|
56
|
+
* identical atomic-write + Windows-retry discipline as conversation JSON, so this is the shared
|
|
57
|
+
* primitive both now go through rather than a second copy of the temp-write+rename dance.
|
|
58
|
+
*/
|
|
59
|
+
function writeFileAtomic(filePath, data, encoding) {
|
|
50
60
|
fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
|
|
51
61
|
const tempPath = `${filePath}.${process.pid}.${tempSeq++}.tmp`;
|
|
52
|
-
|
|
62
|
+
if (typeof data === 'string') {
|
|
63
|
+
fs_1.default.writeFileSync(tempPath, data, encoding || 'utf8');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
fs_1.default.writeFileSync(tempPath, data);
|
|
67
|
+
}
|
|
53
68
|
try {
|
|
54
69
|
renameWithRetry(tempPath, filePath);
|
|
55
70
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AttachmentRejectedError = exports.MAX_ATTACHMENTS_PER_SEND = exports.MAX_UPLOAD_BODY_BYTES = exports.MAX_ATTACHMENT_BYTES = exports.ACCEPTED_ATTACHMENT_MIME_TYPES = void 0;
|
|
7
|
+
exports.assertAttachmentUploadAllowed = assertAttachmentUploadAllowed;
|
|
8
|
+
exports.sanitizeAttachmentBuffer = sanitizeAttachmentBuffer;
|
|
9
|
+
exports.safeAttachmentBaseName = safeAttachmentBaseName;
|
|
10
|
+
exports.attachmentDiskFilename = attachmentDiskFilename;
|
|
11
|
+
exports.safeAttachmentMetaPath = safeAttachmentMetaPath;
|
|
12
|
+
/**
|
|
13
|
+
* Hub message attachments — Issue #1651 (Hub attachments and screenshots).
|
|
14
|
+
*
|
|
15
|
+
* Deterministic, dependency-isolated helpers for validating and sanitizing a
|
|
16
|
+
* manager-uploaded attachment before it ever reaches disk. Kept separate from
|
|
17
|
+
* `server.ts` so the route handler stays a thin composition of these pure
|
|
18
|
+
* steps (architecture-standards §2/§3).
|
|
19
|
+
*
|
|
20
|
+
* Security posture (spec R10/R11, technical design "Sanitization"): an
|
|
21
|
+
* uploaded file is untrusted input. Images are re-encoded (not merely
|
|
22
|
+
* renamed/copied) via `sharp`, which strips any non-pixel payload — embedded
|
|
23
|
+
* scripts, polyglot content appended after the image's real end-of-data
|
|
24
|
+
* marker — as an inherent side effect of decoding + re-encoding pixel data.
|
|
25
|
+
* PDFs are scanned for `/JavaScript` / `/JS` object markers and rejected if
|
|
26
|
+
* found; this is a narrow heuristic, not a full sanitizer (documented as a
|
|
27
|
+
* known limitation in the technical design, not a claimed complete defense).
|
|
28
|
+
* Plain text/log files are stored unmodified — this delivery model (R12)
|
|
29
|
+
* never renders or executes attachment content, only hands the agent a file
|
|
30
|
+
* path its own file-read tool opens.
|
|
31
|
+
*/
|
|
32
|
+
const path_1 = __importDefault(require("path"));
|
|
33
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
34
|
+
/** Accepted upload MIME types (spec R1/R11). */
|
|
35
|
+
exports.ACCEPTED_ATTACHMENT_MIME_TYPES = new Set([
|
|
36
|
+
'image/png',
|
|
37
|
+
'image/jpeg',
|
|
38
|
+
'image/gif',
|
|
39
|
+
'application/pdf',
|
|
40
|
+
'text/plain',
|
|
41
|
+
]);
|
|
42
|
+
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif']);
|
|
43
|
+
/** Per-file cap (spec R5/R11): matches the client-side pre-check exactly. */
|
|
44
|
+
exports.MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
45
|
+
/**
|
|
46
|
+
* Server route body-size cap (technical design "Server-side enforcement"): 1 MB
|
|
47
|
+
* headroom above `MAX_ATTACHMENT_BYTES` absorbs transfer overhead. The 10 MB
|
|
48
|
+
* per-file cap itself is still enforced explicitly against the decoded body
|
|
49
|
+
* length so a file just under this outer limit but over the real cap is a
|
|
50
|
+
* clean 413, not a silent accept.
|
|
51
|
+
*/
|
|
52
|
+
exports.MAX_UPLOAD_BODY_BYTES = 11 * 1024 * 1024;
|
|
53
|
+
/** Design-owned bound (technical design "Count/size bounds not specified by the spec"). */
|
|
54
|
+
exports.MAX_ATTACHMENTS_PER_SEND = 5;
|
|
55
|
+
/** Thrown by validation/sanitization for a request-level (never a server-fault) rejection. */
|
|
56
|
+
class AttachmentRejectedError extends Error {
|
|
57
|
+
constructor(status, message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = 'AttachmentRejectedError';
|
|
60
|
+
this.status = status;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.AttachmentRejectedError = AttachmentRejectedError;
|
|
64
|
+
/** R11: server-side size/type enforcement, independent of the client-side R5 check. */
|
|
65
|
+
function assertAttachmentUploadAllowed(mimeType, sizeBytes) {
|
|
66
|
+
if (!exports.ACCEPTED_ATTACHMENT_MIME_TYPES.has(mimeType)) {
|
|
67
|
+
throw new AttachmentRejectedError(415, `Unsupported attachment type: ${mimeType || '(none)'}`);
|
|
68
|
+
}
|
|
69
|
+
if (sizeBytes > exports.MAX_ATTACHMENT_BYTES) {
|
|
70
|
+
throw new AttachmentRejectedError(413, `Attachment exceeds the ${exports.MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* PDF `/JavaScript` or `/JS` object-marker scan (technical design "Sanitization").
|
|
75
|
+
* Deliberately narrow: this is a reject-on-suspicious-marker gate, not a full
|
|
76
|
+
* PDF parser or sanitizer — sufficient because R1 requires only safe storage
|
|
77
|
+
* and a later file-path handoff to the agent (R12), never PDF rendering.
|
|
78
|
+
*/
|
|
79
|
+
function pdfContainsScriptMarker(buffer) {
|
|
80
|
+
// Scan the raw bytes as latin1 so multi-byte content can't hide a marker;
|
|
81
|
+
// PDF object names are always ASCII (`/JavaScript`, `/JS`).
|
|
82
|
+
const text = buffer.toString('latin1');
|
|
83
|
+
return /\/JavaScript\b/.test(text) || /\/JS\b/.test(text);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Re-encode an image (stripping any non-pixel payload) or reject a suspicious
|
|
87
|
+
* PDF. Text/plain passes through unmodified. Throws `AttachmentRejectedError`
|
|
88
|
+
* for a PDF script marker or a buffer `sharp` cannot decode as a valid image.
|
|
89
|
+
*/
|
|
90
|
+
async function sanitizeAttachmentBuffer(buffer, mimeType) {
|
|
91
|
+
if (IMAGE_MIME_TYPES.has(mimeType)) {
|
|
92
|
+
try {
|
|
93
|
+
const format = mimeType === 'image/jpeg' ? 'jpeg' : mimeType === 'image/gif' ? 'gif' : 'png';
|
|
94
|
+
return await (0, sharp_1.default)(buffer).toFormat(format).toBuffer();
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
throw new AttachmentRejectedError(415, `Not a valid ${mimeType} image.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (mimeType === 'application/pdf') {
|
|
101
|
+
if (pdfContainsScriptMarker(buffer)) {
|
|
102
|
+
throw new AttachmentRejectedError(415, 'PDF contains an embedded script marker and was rejected.');
|
|
103
|
+
}
|
|
104
|
+
return buffer;
|
|
105
|
+
}
|
|
106
|
+
// text/plain (the only remaining accepted type): no execution surface in
|
|
107
|
+
// this delivery model, stored as-is.
|
|
108
|
+
return buffer;
|
|
109
|
+
}
|
|
110
|
+
/** Strip path separators/control characters and cap length for a disk-safe filename. */
|
|
111
|
+
function safeAttachmentBaseName(filename) {
|
|
112
|
+
const trimmed = typeof filename === 'string' ? filename.trim() : '';
|
|
113
|
+
// eslint-disable-next-line no-control-regex
|
|
114
|
+
const cleaned = trimmed.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 150);
|
|
115
|
+
return cleaned || 'file';
|
|
116
|
+
}
|
|
117
|
+
/** Disk filename for an attachment: `<id>-<safeName>` (technical design "Storage"). */
|
|
118
|
+
function attachmentDiskFilename(id, filename) {
|
|
119
|
+
return `${id}-${safeAttachmentBaseName(filename)}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve `<dir>/<id>.meta.json`, rejecting an `id` that would escape `dir`.
|
|
123
|
+
*
|
|
124
|
+
* Security: `id` reaches this function from a URL route param (`GET .../attachments/:attachmentId`)
|
|
125
|
+
* or a client-supplied `attachmentIds[]` entry (message send) with no format constraint of its own —
|
|
126
|
+
* `attachmentsDirFor` only validates `conversationId`, not this per-attachment id. Without this
|
|
127
|
+
* check, an id like `../../../etc/passwd` would let a caller read (or, combined with a write path,
|
|
128
|
+
* write) outside the intended attachments directory via ordinary `path.join`/`path.resolve` `..`
|
|
129
|
+
* handling.
|
|
130
|
+
*/
|
|
131
|
+
function safeAttachmentMetaPath(dir, id) {
|
|
132
|
+
const metaPath = path_1.default.resolve(dir, `${id}.meta.json`);
|
|
133
|
+
if (!metaPath.startsWith(path_1.default.resolve(dir) + path_1.default.sep)) {
|
|
134
|
+
throw new Error('Invalid attachment id.');
|
|
135
|
+
}
|
|
136
|
+
return metaPath;
|
|
137
|
+
}
|
|
@@ -440,6 +440,34 @@ class AiHubConversationStore {
|
|
|
440
440
|
lockPath(bucketDir) {
|
|
441
441
|
return path_1.default.join(bucketDir, '.lock');
|
|
442
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Directory holding attachment blobs for one conversation, nested inside that
|
|
445
|
+
* conversation's own bucket directory (issue #1651).
|
|
446
|
+
*
|
|
447
|
+
* This is the only sanctioned entry point into bucket-path resolution for
|
|
448
|
+
* attachments — every other module reaches attachment storage through this
|
|
449
|
+
* method rather than re-deriving the bucket key, so `bucketKey`'s sentinel
|
|
450
|
+
* handling (`@manager`/`@company`) stays centralized in one place. Nesting
|
|
451
|
+
* under the bucket directory (rather than a sibling root) means
|
|
452
|
+
* `removeProject`'s existing recursive delete already reaches attachments
|
|
453
|
+
* with no new deletion code — see the technical design's Correction 1.
|
|
454
|
+
*/
|
|
455
|
+
attachmentsDirFor(bucketKey, conversationId) {
|
|
456
|
+
const key = normalizeConversationKey(bucketKey);
|
|
457
|
+
const attachmentsRoot = path_1.default.join(this.bucketDir(key), 'attachments');
|
|
458
|
+
const dir = path_1.default.resolve(attachmentsRoot, conversationId);
|
|
459
|
+
// Security: conversationId arrives from a URL route param / client-supplied
|
|
460
|
+
// field with no format constraint (it is not required to be a UUID). Without
|
|
461
|
+
// this containment check, a conversationId like '../../../etc' would resolve
|
|
462
|
+
// outside attachmentsRoot via path.resolve's own '..' handling, giving an
|
|
463
|
+
// attacker a write primitive (upload) and read primitive (download/message
|
|
464
|
+
// send) onto arbitrary paths on disk. Reject anything that does not resolve
|
|
465
|
+
// to a direct child of attachmentsRoot.
|
|
466
|
+
if (!dir.startsWith(attachmentsRoot + path_1.default.sep)) {
|
|
467
|
+
throw new Error('Invalid conversationId.');
|
|
468
|
+
}
|
|
469
|
+
return dir;
|
|
470
|
+
}
|
|
443
471
|
invalidateProjectPathCache() {
|
|
444
472
|
this.projectPathCache = null;
|
|
445
473
|
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -3060,12 +3060,15 @@ class ScriptedHostRuntime {
|
|
|
3060
3060
|
}
|
|
3061
3061
|
}
|
|
3062
3062
|
exports.ScriptedHostRuntime = ScriptedHostRuntime;
|
|
3063
|
-
const createHubMessage = (role, text, deliveryStatus
|
|
3063
|
+
const createHubMessage = (role, text, deliveryStatus,
|
|
3064
|
+
// Issue #1651: files/screenshots the manager attached to this message.
|
|
3065
|
+
attachments) => ({
|
|
3064
3066
|
id: (0, crypto_1.randomUUID)(),
|
|
3065
3067
|
role,
|
|
3066
3068
|
text,
|
|
3067
3069
|
createdAt: new Date().toISOString(),
|
|
3068
3070
|
...(deliveryStatus ? { deliveryStatus } : {}),
|
|
3071
|
+
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
3069
3072
|
});
|
|
3070
3073
|
exports.createHubMessage = createHubMessage;
|
|
3071
3074
|
const createHubEvent = (channel, text) => ({
|
|
@@ -70,6 +70,8 @@ const manager_turns_1 = require("./manager-turns");
|
|
|
70
70
|
const preferences_1 = require("./preferences");
|
|
71
71
|
const conversation_store_1 = require("./conversation-store");
|
|
72
72
|
const raw_event_log_store_1 = require("./raw-event-log-store");
|
|
73
|
+
const atomic_json_file_1 = require("./atomic-json-file");
|
|
74
|
+
const attachment_store_1 = require("./attachment-store");
|
|
73
75
|
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
74
76
|
const run_working_directory_1 = require("./run-working-directory");
|
|
75
77
|
const conversation_search_1 = require("./conversation-search");
|
|
@@ -3974,6 +3976,12 @@ class AiHubServer {
|
|
|
3974
3976
|
text: message.text,
|
|
3975
3977
|
createdAt: message.createdAt,
|
|
3976
3978
|
...(Number.isFinite(at) && at > 0 ? { at } : {}),
|
|
3979
|
+
// Issue #1651: attachment metadata must survive the run -> persisted-record
|
|
3980
|
+
// fold, or a reloaded conversation loses its attachment chips even though
|
|
3981
|
+
// the blobs remain on disk (Wire All Read Paths discipline).
|
|
3982
|
+
...(Array.isArray(message.attachments) && message.attachments.length > 0
|
|
3983
|
+
? { attachments: message.attachments }
|
|
3984
|
+
: {}),
|
|
3977
3985
|
};
|
|
3978
3986
|
}),
|
|
3979
3987
|
events: run.events.map((event) => ({
|
|
@@ -5110,6 +5118,38 @@ class AiHubServer {
|
|
|
5110
5118
|
].filter(Boolean).join('\n\n');
|
|
5111
5119
|
return this.prepareStartPayload(deployment.projectPath, deployment.hostId, deployment.jobId, context);
|
|
5112
5120
|
}
|
|
5121
|
+
/**
|
|
5122
|
+
* Issue #1651 R7/R12: resolve staged attachment ids (uploaded via
|
|
5123
|
+
* POST /api/ai-hub/conversations/:conversationId/attachments) into persisted
|
|
5124
|
+
* metadata for AiHubMessage.attachments, plus a "[Attached: ...]" reference
|
|
5125
|
+
* suffix appended to the host-bound message text. An id that fails to
|
|
5126
|
+
* resolve (already deleted, wrong conversation) is skipped rather than
|
|
5127
|
+
* failing the whole send — the manager's own text still reaches the agent.
|
|
5128
|
+
*/
|
|
5129
|
+
resolveMessageAttachments(bucketKey, conversationId, attachmentIds) {
|
|
5130
|
+
const ids = Array.isArray(attachmentIds)
|
|
5131
|
+
? attachmentIds.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
5132
|
+
: [];
|
|
5133
|
+
if (ids.length === 0)
|
|
5134
|
+
return { attachments: undefined, referenceSuffix: '' };
|
|
5135
|
+
const dir = this.conversationStore.attachmentsDirFor(bucketKey, conversationId);
|
|
5136
|
+
const attachments = [];
|
|
5137
|
+
const refLines = [];
|
|
5138
|
+
for (const id of ids) {
|
|
5139
|
+
try {
|
|
5140
|
+
const raw = JSON.parse(fs_1.default.readFileSync((0, attachment_store_1.safeAttachmentMetaPath)(dir, id), 'utf8'));
|
|
5141
|
+
attachments.push({ id: raw.id, filename: raw.filename, sizeBytes: raw.sizeBytes, mimeType: raw.mimeType });
|
|
5142
|
+
refLines.push(`[Attached: ${raw.filename} at ${path_1.default.resolve(path_1.default.join(dir, raw.diskFilename))}]`);
|
|
5143
|
+
}
|
|
5144
|
+
catch {
|
|
5145
|
+
// Stale/unknown/path-escaping attachment id — skip; the message still sends.
|
|
5146
|
+
}
|
|
5147
|
+
}
|
|
5148
|
+
return {
|
|
5149
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
5150
|
+
referenceSuffix: refLines.length > 0 ? `\n${refLines.join('\n')}` : '',
|
|
5151
|
+
};
|
|
5152
|
+
}
|
|
5113
5153
|
prepareContinueMessage(run, instructions, coachingJobId) {
|
|
5114
5154
|
// coachingJobId is set when the user selected a manager coaching template
|
|
5115
5155
|
// (e.g. follow-your-mentor). When present, it overrides the run's own jobId
|
|
@@ -5972,6 +6012,78 @@ class AiHubServer {
|
|
|
5972
6012
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not switch agents.' });
|
|
5973
6013
|
}
|
|
5974
6014
|
});
|
|
6015
|
+
// Issue #1651: Hub message attachments — binary upload transport.
|
|
6016
|
+
// Route-scoped raw body parser (never `app.use()` at global scope): the client sends the
|
|
6017
|
+
// attachment's real MIME type as Content-Type (image/png, application/pdf, ...), never
|
|
6018
|
+
// application/json or multipart/*. express.json() (registered globally above) only consumes
|
|
6019
|
+
// the request stream when req.is('json') matches; for any other Content-Type it calls next()
|
|
6020
|
+
// without touching the stream, leaving it untouched for this route's express.raw() — confirmed
|
|
6021
|
+
// by a real spike against this exact middleware order (see the technical design's "Spike
|
|
6022
|
+
// Findings") and pinned by the byte-identity regression test in test-ai-hub-attachments.ts.
|
|
6023
|
+
this.app.post('/api/ai-hub/conversations/:conversationId/attachments', express_1.default.raw({ type: () => true, limit: attachment_store_1.MAX_UPLOAD_BODY_BYTES }), async (req, res) => {
|
|
6024
|
+
try {
|
|
6025
|
+
const conversationId = req.params.conversationId;
|
|
6026
|
+
const scope = scopeParam(req.query.scope);
|
|
6027
|
+
if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
|
|
6028
|
+
return res.status(400).json({ error: 'projectPath required for project conversations' });
|
|
6029
|
+
}
|
|
6030
|
+
const bucketKey = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(req.query.projectPath);
|
|
6031
|
+
const body = req.body;
|
|
6032
|
+
if (!Buffer.isBuffer(body) || body.length === 0) {
|
|
6033
|
+
return res.status(400).json({ error: 'Expected a non-empty binary request body.' });
|
|
6034
|
+
}
|
|
6035
|
+
const mimeType = (req.headers['content-type'] || '').toString().split(';')[0].trim().toLowerCase();
|
|
6036
|
+
(0, attachment_store_1.assertAttachmentUploadAllowed)(mimeType, body.length);
|
|
6037
|
+
const filenameHeader = req.headers['x-attachment-filename'];
|
|
6038
|
+
let rawFilename = 'attachment';
|
|
6039
|
+
if (typeof filenameHeader === 'string' && filenameHeader) {
|
|
6040
|
+
try {
|
|
6041
|
+
rawFilename = decodeURIComponent(filenameHeader);
|
|
6042
|
+
}
|
|
6043
|
+
catch {
|
|
6044
|
+
rawFilename = filenameHeader;
|
|
6045
|
+
}
|
|
6046
|
+
}
|
|
6047
|
+
const sanitized = await (0, attachment_store_1.sanitizeAttachmentBuffer)(body, mimeType);
|
|
6048
|
+
const id = (0, crypto_1.randomUUID)();
|
|
6049
|
+
const dir = this.conversationStore.attachmentsDirFor(bucketKey, conversationId);
|
|
6050
|
+
const diskFilename = (0, attachment_store_1.attachmentDiskFilename)(id, rawFilename);
|
|
6051
|
+
const filename = (0, attachment_store_1.safeAttachmentBaseName)(rawFilename);
|
|
6052
|
+
(0, atomic_json_file_1.writeFileAtomic)(path_1.default.join(dir, diskFilename), sanitized);
|
|
6053
|
+
(0, atomic_json_file_1.writeFileAtomic)(path_1.default.join(dir, `${id}.meta.json`), JSON.stringify({ id, filename, sizeBytes: sanitized.length, mimeType, diskFilename }));
|
|
6054
|
+
console.info('[ai-hub] hub.attachment_uploaded', { conversationId, id, mimeType, sizeBytes: sanitized.length });
|
|
6055
|
+
return res.status(201).json({ id, filename, sizeBytes: sanitized.length, mimeType });
|
|
6056
|
+
}
|
|
6057
|
+
catch (error) {
|
|
6058
|
+
if (error instanceof attachment_store_1.AttachmentRejectedError) {
|
|
6059
|
+
console.warn('[ai-hub] hub.attachment_rejected', { status: error.status, reason: error.message });
|
|
6060
|
+
return res.status(error.status).json({ error: error.message });
|
|
6061
|
+
}
|
|
6062
|
+
console.error('[ai-hub] hub.attachment_upload_failed', error instanceof Error ? error.message : error);
|
|
6063
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Attachment upload failed.' });
|
|
6064
|
+
}
|
|
6065
|
+
});
|
|
6066
|
+
// Issue #1651: byte-serve one attachment for transcript thumbnail/chip rendering
|
|
6067
|
+
// (`<img src="...">` for images; metadata-driven chip for everything else).
|
|
6068
|
+
this.app.get('/api/ai-hub/conversations/:conversationId/attachments/:attachmentId', (req, res) => {
|
|
6069
|
+
try {
|
|
6070
|
+
const scope = scopeParam(req.query.scope);
|
|
6071
|
+
if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
|
|
6072
|
+
return res.status(400).json({ error: 'projectPath required for project conversations' });
|
|
6073
|
+
}
|
|
6074
|
+
const bucketKey = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(req.query.projectPath);
|
|
6075
|
+
const dir = this.conversationStore.attachmentsDirFor(bucketKey, req.params.conversationId);
|
|
6076
|
+
const metadata = JSON.parse(fs_1.default.readFileSync((0, attachment_store_1.safeAttachmentMetaPath)(dir, req.params.attachmentId), 'utf8'));
|
|
6077
|
+
const bytes = fs_1.default.readFileSync(path_1.default.join(dir, metadata.diskFilename));
|
|
6078
|
+
res.setHeader('Content-Type', metadata.mimeType);
|
|
6079
|
+
res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
|
|
6080
|
+
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(metadata.filename)}"`);
|
|
6081
|
+
return res.send(bytes);
|
|
6082
|
+
}
|
|
6083
|
+
catch {
|
|
6084
|
+
return res.status(404).json({ error: 'Attachment not found.' });
|
|
6085
|
+
}
|
|
6086
|
+
});
|
|
5975
6087
|
// Issue #1065: conversation search. Served by the existing loopback listener (C6) — no new
|
|
5976
6088
|
// endpoint surface, no new trust boundary. Reads only the bucket index (header facts) and the
|
|
5977
6089
|
// searchable projection (thread text); never a conversation body, which is where the raw host
|
|
@@ -6655,6 +6767,15 @@ class AiHubServer {
|
|
|
6655
6767
|
});
|
|
6656
6768
|
if (!continuity.ok)
|
|
6657
6769
|
return res.status(continuity.status).json(continuity.body);
|
|
6770
|
+
// Issue #1651 R7/R8: attachments staged before the first message of a brand-new
|
|
6771
|
+
// conversation. Requires the client to have already generated/supplied a
|
|
6772
|
+
// conversationId (StartRunBody.conversationId already exists for this reason).
|
|
6773
|
+
if (Array.isArray(req.body.attachmentIds) && req.body.attachmentIds.length > 0 && !requestedConversationId) {
|
|
6774
|
+
return res.status(400).json({ error: 'conversationId is required to attach files.' });
|
|
6775
|
+
}
|
|
6776
|
+
const { attachments: startAttachments, referenceSuffix: startAttachmentSuffix } = requestedConversationId
|
|
6777
|
+
? this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(scope, projectPath), requestedConversationId, req.body.attachmentIds)
|
|
6778
|
+
: { attachments: undefined, referenceSuffix: '' };
|
|
6658
6779
|
const run = {
|
|
6659
6780
|
id: (0, crypto_1.randomUUID)(),
|
|
6660
6781
|
conversationId: requestedConversationId,
|
|
@@ -6669,7 +6790,7 @@ class AiHubServer {
|
|
|
6669
6790
|
status: 'running',
|
|
6670
6791
|
createdAt: startTimestamp,
|
|
6671
6792
|
updatedAt: startTimestamp,
|
|
6672
|
-
messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
|
|
6793
|
+
messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay, undefined, startAttachments)],
|
|
6673
6794
|
events: [(0, hosts_1.createHubEvent)('system', `Starting ${configuredAgent.label} (${hostId}) in ${projectPath}`)],
|
|
6674
6795
|
// Issue #347 — seed phase + totals state on creation.
|
|
6675
6796
|
currentPhase: null,
|
|
@@ -6735,7 +6856,7 @@ class AiHubServer {
|
|
|
6735
6856
|
this.runRegistry.create(directRun, {});
|
|
6736
6857
|
this.scheduleRunConversationPersistence(directRun, directRun.id);
|
|
6737
6858
|
}
|
|
6738
|
-
const child = this.hostRuntime.startRun(hostId, projectPath, message, {
|
|
6859
|
+
const child = this.hostRuntime.startRun(hostId, projectPath, message + startAttachmentSuffix, {
|
|
6739
6860
|
onEvent: (event, channel) => {
|
|
6740
6861
|
this.runRegistry.update(run.id, (current) => {
|
|
6741
6862
|
if (event.sessionId) {
|
|
@@ -6919,9 +7040,13 @@ class AiHubServer {
|
|
|
6919
7040
|
? this.prepareContinueMessage(run, '', coachingJobId)
|
|
6920
7041
|
: { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
|
|
6921
7042
|
const message = prepared.message;
|
|
6922
|
-
|
|
7043
|
+
const hasAttachments = Array.isArray(req.body.attachmentIds) && req.body.attachmentIds.length > 0;
|
|
7044
|
+
if (!message && !hasAttachments) {
|
|
6923
7045
|
return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
|
|
6924
7046
|
}
|
|
7047
|
+
// Issue #1651 R7/R12: resolve staged attachments into persisted metadata
|
|
7048
|
+
// (attached to the manager's message) and a host-bound reference suffix.
|
|
7049
|
+
const { attachments, referenceSuffix: attachmentSuffix } = this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, req.body.attachmentIds);
|
|
6925
7050
|
// No resumable session — start fresh using a handoff prompt so the agent
|
|
6926
7051
|
// picks up from the preserved conversation state + manager coaching.
|
|
6927
7052
|
const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
|
|
@@ -6945,7 +7070,7 @@ class AiHubServer {
|
|
|
6945
7070
|
// to failed/stopped by the stale flag from the original stop.
|
|
6946
7071
|
current.stoppedByUser = false;
|
|
6947
7072
|
current.sessionId = undefined;
|
|
6948
|
-
const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message);
|
|
7073
|
+
const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, undefined, attachments);
|
|
6949
7074
|
current.messages.push(managerMessage);
|
|
6950
7075
|
appendDeliveredManagerMicroEvent(current, managerMessage);
|
|
6951
7076
|
if (reviewApprovalSystemEventText)
|
|
@@ -6969,7 +7094,7 @@ class AiHubServer {
|
|
|
6969
7094
|
this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
|
|
6970
7095
|
this.runRegistry.create(run, {});
|
|
6971
7096
|
const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
|
|
6972
|
-
const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
|
|
7097
|
+
const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message + attachmentSuffix, {
|
|
6973
7098
|
onEvent: (event, channel) => {
|
|
6974
7099
|
this.runRegistry.update(run.id, (current) => {
|
|
6975
7100
|
if (event.sessionId) {
|
|
@@ -7021,7 +7146,7 @@ class AiHubServer {
|
|
|
7021
7146
|
// to failed/stopped by the stale flag from the original stop.
|
|
7022
7147
|
current.stoppedByUser = false;
|
|
7023
7148
|
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
7024
|
-
const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus);
|
|
7149
|
+
const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus, attachments);
|
|
7025
7150
|
current.messages.push(managerMessage);
|
|
7026
7151
|
appendDeliveredManagerMicroEvent(current, managerMessage);
|
|
7027
7152
|
if (reviewApprovalSystemEventText)
|
|
@@ -7054,7 +7179,7 @@ class AiHubServer {
|
|
|
7054
7179
|
// them, so any other continueRun call site delivering the turn (or a
|
|
7055
7180
|
// turn that exits before its first event) left the badge stuck
|
|
7056
7181
|
// forever. See clearStaleDeliveryStatuses and handleRunExit.
|
|
7057
|
-
const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
|
|
7182
|
+
const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message + attachmentSuffix, {
|
|
7058
7183
|
onEvent: (event, channel) => {
|
|
7059
7184
|
if (run.hostId === 'codex' && reviewApprovalSystemEventText) {
|
|
7060
7185
|
codexMissingReasoningResumeError = codexMissingReasoningResumeError ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.316",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"electron-updater": "^6.8.9",
|
|
212
212
|
"express": "^5.2.1",
|
|
213
213
|
"extract-zip": "^2.0.1",
|
|
214
|
-
"fraim": "2.0.
|
|
214
|
+
"fraim": "2.0.316",
|
|
215
215
|
"mongodb": "^7.0.0",
|
|
216
216
|
"node-cron": "4.2.1",
|
|
217
217
|
"node-edge-tts": "^1.2.10",
|
|
@@ -220,6 +220,7 @@
|
|
|
220
220
|
"resend": "^6.9.3",
|
|
221
221
|
"selfsigned": "^5.5.0",
|
|
222
222
|
"semver": "^7.7.4",
|
|
223
|
+
"sharp": "^0.34.5",
|
|
223
224
|
"stripe": "^20.3.1",
|
|
224
225
|
"tar": "^7.4.3",
|
|
225
226
|
"toml": "^3.0.0",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -499,7 +499,10 @@
|
|
|
499
499
|
</span>
|
|
500
500
|
</summary>
|
|
501
501
|
<div class="panel-body">
|
|
502
|
-
<div class="coach">
|
|
502
|
+
<div class="coach" id="coach">
|
|
503
|
+
<!-- Issue #1651: dashed overlay shown only while a drag is over .coach
|
|
504
|
+
(toggled via the coach.drag-active class in script.js). -->
|
|
505
|
+
<div class="composer-dropzone" id="composer-dropzone">Drop to attach</div>
|
|
503
506
|
<!-- Issue #512 R7.7: review actions live in the SAME bar as the
|
|
504
507
|
coaching chips. Hidden until a deliverable is awaiting review;
|
|
505
508
|
the coaching chips below never disappear. -->
|
|
@@ -589,10 +592,16 @@
|
|
|
589
592
|
<button class="delivery-option delivery-option--redirect" type="button" id="delivery-redirect" aria-pressed="false">Redirect now</button>
|
|
590
593
|
</div>
|
|
591
594
|
<p class="delivery-help" id="delivery-help" hidden></p>
|
|
595
|
+
<!-- Issue #1651: staged attachment chips (thumbnail/file icon, filename,
|
|
596
|
+
size, remove control), rendered before Send by renderStagedAttachments(). -->
|
|
597
|
+
<div class="staged-attachments" id="staged-attachments"></div>
|
|
592
598
|
<div class="coach-input">
|
|
593
|
-
<
|
|
599
|
+
<button class="attach-btn" type="button" id="attach-btn" aria-label="Attach a file or screenshot" title="Attach a file or screenshot">📎</button>
|
|
600
|
+
<input type="file" id="attach-file-input" multiple accept="image/png,image/jpeg,image/gif,.pdf,.txt,.log" hidden>
|
|
601
|
+
<textarea id="coach-text" aria-label="Coach the employee" placeholder="Tell the employee what to do next... (paste a screenshot with Ctrl+V)"></textarea>
|
|
594
602
|
<button class="send-button" type="button" id="send" aria-label="Send coaching message" title="Send coaching message" disabled>↑</button>
|
|
595
603
|
</div>
|
|
604
|
+
<div class="composer-hint">Attach or paste a screenshot, log, or PDF, up to 10 MB each.</div>
|
|
596
605
|
<div class="coach-note" id="coach-note"></div>
|
|
597
606
|
</div>
|
|
598
607
|
</div>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -100,6 +100,11 @@ const state = {
|
|
|
100
100
|
brandEditorStatusWarn: false,
|
|
101
101
|
// Issue #540 R10: pending run params captured when hire strip is shown.
|
|
102
102
|
_hireStripPending: null,
|
|
103
|
+
// Issue #1651: files/screenshots staged in the composer before Send.
|
|
104
|
+
// { localId, file, dataUrl (images only), error }. Scoped to the active
|
|
105
|
+
// conversation the same way pendingCoachingJobId is: cleared on send and on
|
|
106
|
+
// conversation switch (see clearStagedAttachments and the switch handler).
|
|
107
|
+
stagedAttachments: [],
|
|
103
108
|
};
|
|
104
109
|
|
|
105
110
|
const bootstrapCache = new Map();
|
|
@@ -122,6 +127,8 @@ function gatherElements() {
|
|
|
122
127
|
'messages',
|
|
123
128
|
'coach-text', 'send', 'micro-manage', 'micro-log', 'resume-command', 'resume-command-code', 'resume-command-copy',
|
|
124
129
|
'status-line', 'coach-note',
|
|
130
|
+
// Issue #1651: composer attachments.
|
|
131
|
+
'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input',
|
|
125
132
|
// Issue #1176: continue-run delivery control.
|
|
126
133
|
'delivery-control', 'delivery-after', 'delivery-redirect', 'delivery-help',
|
|
127
134
|
'coach-panel', 'coach-summary',
|
|
@@ -1866,6 +1873,16 @@ function conversationStartTimestamp(conv) {
|
|
|
1866
1873
|
return timestampMillis(conv.createdAt) || conversationTimestamp(conv);
|
|
1867
1874
|
}
|
|
1868
1875
|
|
|
1876
|
+
// Stable kickoff-time comparator for ordering runs by when they were kicked off, not by
|
|
1877
|
+
// their last activity. Shared by the Projects rail (renderRail's activeConvs/doneConvs
|
|
1878
|
+
// sort) and the Manager/Company rail (tfAreaScopedConversations) so "In Progress"
|
|
1879
|
+
// ordering is identical across all three tabs (#1652). Sorting by lastUpdatedAt instead
|
|
1880
|
+
// reshuffles the list on every update a run receives; createdAt pins a run where it was
|
|
1881
|
+
// kicked off and never changes again.
|
|
1882
|
+
function compareByKickoffTime(a, b) {
|
|
1883
|
+
return conversationStartTimestamp(b) - conversationStartTimestamp(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1869
1886
|
function timestampMillis(value) {
|
|
1870
1887
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
|
1871
1888
|
if (typeof value === 'string') {
|
|
@@ -2726,8 +2743,8 @@ function renderRail() {
|
|
|
2726
2743
|
}
|
|
2727
2744
|
}
|
|
2728
2745
|
// Latest kickoff first within each section.
|
|
2729
|
-
activeConvs.sort(
|
|
2730
|
-
doneConvs.sort(
|
|
2746
|
+
activeConvs.sort(compareByKickoffTime);
|
|
2747
|
+
doneConvs.sort(compareByKickoffTime);
|
|
2731
2748
|
|
|
2732
2749
|
// Build per-employee groups for completed runs.
|
|
2733
2750
|
// Completed runs with a personaKey group under that employee; completed runs
|
|
@@ -3896,6 +3913,9 @@ function renderActive() {
|
|
|
3896
3913
|
// A pending coaching job is scoped to the active conversation — discard it
|
|
3897
3914
|
// whenever the user switches to a different conversation.
|
|
3898
3915
|
clearPendingCoachingJob();
|
|
3916
|
+
// Issue #1651: staged attachments are equally scoped to the active
|
|
3917
|
+
// conversation — a switch must not carry them into a different thread.
|
|
3918
|
+
clearStagedAttachments();
|
|
3899
3919
|
// Issue #1176 R40: the delivery control resets to "After current" whenever
|
|
3900
3920
|
// the active conversation changes, so a prior urgent choice can't redirect
|
|
3901
3921
|
// a different conversation's next guidance.
|
|
@@ -5556,6 +5576,29 @@ function appendMessageDom(role, text, conv, message) {
|
|
|
5556
5576
|
article.appendChild(raw);
|
|
5557
5577
|
}
|
|
5558
5578
|
article.appendChild(bubble);
|
|
5579
|
+
// Issue #1651 R13: one chip per persisted attachment — thumbnail for images
|
|
5580
|
+
// (byte-served by GET .../attachments/:id), a generic file glyph otherwise.
|
|
5581
|
+
if (message && Array.isArray(message.attachments)) {
|
|
5582
|
+
message.attachments.forEach((att) => {
|
|
5583
|
+
const chip = document.createElement('span');
|
|
5584
|
+
chip.className = 'msg-attachment';
|
|
5585
|
+
const thumb = document.createElement('span');
|
|
5586
|
+
thumb.className = 'thumb';
|
|
5587
|
+
if (att.mimeType && att.mimeType.startsWith('image/')) {
|
|
5588
|
+
const img = document.createElement('img');
|
|
5589
|
+
img.src = attachmentUrlFor(conv, att.id);
|
|
5590
|
+
img.alt = att.filename;
|
|
5591
|
+
thumb.appendChild(img);
|
|
5592
|
+
} else {
|
|
5593
|
+
thumb.textContent = '\u{1F4C4}'; // 📄
|
|
5594
|
+
}
|
|
5595
|
+
const label = document.createElement('span');
|
|
5596
|
+
label.textContent = `${att.filename} (${formatAttachmentSize(att.sizeBytes)})`;
|
|
5597
|
+
chip.appendChild(thumb);
|
|
5598
|
+
chip.appendChild(label);
|
|
5599
|
+
article.appendChild(chip);
|
|
5600
|
+
});
|
|
5601
|
+
}
|
|
5559
5602
|
els['messages'].appendChild(article);
|
|
5560
5603
|
}
|
|
5561
5604
|
|
|
@@ -5782,6 +5825,160 @@ function scrollThreadForReview(conv) {
|
|
|
5782
5825
|
host.scrollTop = desiredTop;
|
|
5783
5826
|
}
|
|
5784
5827
|
|
|
5828
|
+
// ---------------------------------------------------------------------------
|
|
5829
|
+
// Issue #1651: composer attachments (attach button, paste, drag-drop, upload)
|
|
5830
|
+
// ---------------------------------------------------------------------------
|
|
5831
|
+
|
|
5832
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
5833
|
+
const MAX_ATTACHMENTS_PER_SEND = 5;
|
|
5834
|
+
const ACCEPTED_ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'application/pdf', 'text/plain']);
|
|
5835
|
+
let stagedAttachmentSeq = 0;
|
|
5836
|
+
|
|
5837
|
+
function formatAttachmentSize(bytes) {
|
|
5838
|
+
if (bytes < 1024) return bytes + ' B';
|
|
5839
|
+
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
|
|
5840
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
5841
|
+
}
|
|
5842
|
+
|
|
5843
|
+
function hasValidStagedAttachment() {
|
|
5844
|
+
return state.stagedAttachments.some((a) => !a.error);
|
|
5845
|
+
}
|
|
5846
|
+
|
|
5847
|
+
function renderStagedAttachments() {
|
|
5848
|
+
const row = els['staged-attachments'];
|
|
5849
|
+
if (!row) return;
|
|
5850
|
+
row.innerHTML = '';
|
|
5851
|
+
state.stagedAttachments.forEach((item) => {
|
|
5852
|
+
const chip = document.createElement('div');
|
|
5853
|
+
chip.className = 'attachment-chip' + (item.error ? ' chip-error' : '');
|
|
5854
|
+
|
|
5855
|
+
const thumb = document.createElement('span');
|
|
5856
|
+
thumb.className = 'thumb';
|
|
5857
|
+
if (item.dataUrl) {
|
|
5858
|
+
const img = document.createElement('img');
|
|
5859
|
+
img.src = item.dataUrl;
|
|
5860
|
+
img.alt = '';
|
|
5861
|
+
thumb.appendChild(img);
|
|
5862
|
+
} else {
|
|
5863
|
+
thumb.textContent = '\u{1F4C4}'; // 📄
|
|
5864
|
+
}
|
|
5865
|
+
|
|
5866
|
+
const meta = document.createElement('span');
|
|
5867
|
+
meta.className = 'meta';
|
|
5868
|
+
const fname = document.createElement('span');
|
|
5869
|
+
fname.className = 'fname';
|
|
5870
|
+
fname.textContent = item.file.name;
|
|
5871
|
+
meta.appendChild(fname);
|
|
5872
|
+
meta.appendChild(document.createElement('br'));
|
|
5873
|
+
if (item.error) {
|
|
5874
|
+
const err = document.createElement('span');
|
|
5875
|
+
err.className = 'chip-error-text';
|
|
5876
|
+
err.textContent = item.error;
|
|
5877
|
+
meta.appendChild(err);
|
|
5878
|
+
} else {
|
|
5879
|
+
const size = document.createElement('span');
|
|
5880
|
+
size.className = 'fsize';
|
|
5881
|
+
size.textContent = formatAttachmentSize(item.file.size);
|
|
5882
|
+
meta.appendChild(size);
|
|
5883
|
+
}
|
|
5884
|
+
|
|
5885
|
+
const remove = document.createElement('button');
|
|
5886
|
+
remove.className = 'chip-remove';
|
|
5887
|
+
remove.type = 'button';
|
|
5888
|
+
remove.setAttribute('aria-label', `Remove ${item.file.name}`);
|
|
5889
|
+
remove.textContent = '×'; // ×
|
|
5890
|
+
remove.addEventListener('click', () => {
|
|
5891
|
+
state.stagedAttachments = state.stagedAttachments.filter((a) => a.localId !== item.localId);
|
|
5892
|
+
renderStagedAttachments();
|
|
5893
|
+
syncSendButton();
|
|
5894
|
+
});
|
|
5895
|
+
|
|
5896
|
+
chip.appendChild(thumb);
|
|
5897
|
+
chip.appendChild(meta);
|
|
5898
|
+
chip.appendChild(remove);
|
|
5899
|
+
row.appendChild(chip);
|
|
5900
|
+
});
|
|
5901
|
+
}
|
|
5902
|
+
|
|
5903
|
+
function clearStagedAttachments() {
|
|
5904
|
+
state.stagedAttachments = [];
|
|
5905
|
+
renderStagedAttachments();
|
|
5906
|
+
}
|
|
5907
|
+
|
|
5908
|
+
// R5: an oversized or unsupported file stages as a visually distinct error
|
|
5909
|
+
// chip and is excluded from send; R4: everything else stages with a
|
|
5910
|
+
// thumbnail (images, via FileReader) or a generic file icon.
|
|
5911
|
+
function stageAttachmentFile(file) {
|
|
5912
|
+
if (!file || state.stagedAttachments.length >= MAX_ATTACHMENTS_PER_SEND) return;
|
|
5913
|
+
const localId = 'att-' + (stagedAttachmentSeq += 1);
|
|
5914
|
+
if (file.size > MAX_ATTACHMENT_BYTES) {
|
|
5915
|
+
state.stagedAttachments.push({ localId, file, dataUrl: null, error: `Over ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit, not attached` });
|
|
5916
|
+
renderStagedAttachments();
|
|
5917
|
+
syncSendButton();
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5920
|
+
if (!ACCEPTED_ATTACHMENT_MIME_TYPES.has(file.type)) {
|
|
5921
|
+
state.stagedAttachments.push({ localId, file, dataUrl: null, error: 'Unsupported file type, not attached' });
|
|
5922
|
+
renderStagedAttachments();
|
|
5923
|
+
syncSendButton();
|
|
5924
|
+
return;
|
|
5925
|
+
}
|
|
5926
|
+
if (file.type.startsWith('image/')) {
|
|
5927
|
+
const reader = new FileReader();
|
|
5928
|
+
reader.onload = () => {
|
|
5929
|
+
state.stagedAttachments.push({ localId, file, dataUrl: reader.result, error: null });
|
|
5930
|
+
renderStagedAttachments();
|
|
5931
|
+
syncSendButton();
|
|
5932
|
+
};
|
|
5933
|
+
reader.readAsDataURL(file);
|
|
5934
|
+
} else {
|
|
5935
|
+
state.stagedAttachments.push({ localId, file, dataUrl: null, error: null });
|
|
5936
|
+
renderStagedAttachments();
|
|
5937
|
+
syncSendButton();
|
|
5938
|
+
}
|
|
5939
|
+
}
|
|
5940
|
+
|
|
5941
|
+
// R7: upload every valid staged file to the per-conversation attachment
|
|
5942
|
+
// endpoint, returning the ids the subsequent message-send call references.
|
|
5943
|
+
async function uploadStagedAttachments(conv) {
|
|
5944
|
+
const valid = state.stagedAttachments.filter((a) => !a.error);
|
|
5945
|
+
if (valid.length === 0) return [];
|
|
5946
|
+
const scope = convScope(conv);
|
|
5947
|
+
const params = new URLSearchParams();
|
|
5948
|
+
if (scope === 'manager' || scope === 'company') {
|
|
5949
|
+
params.set('scope', scope);
|
|
5950
|
+
} else {
|
|
5951
|
+
params.set('projectPath', (conv && conv.projectPath) || state.projectPath || '');
|
|
5952
|
+
}
|
|
5953
|
+
const url = `/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/attachments?${params.toString()}`;
|
|
5954
|
+
const ids = [];
|
|
5955
|
+
for (const item of valid) {
|
|
5956
|
+
const result = await requestJson(url, {
|
|
5957
|
+
method: 'POST',
|
|
5958
|
+
headers: {
|
|
5959
|
+
'Content-Type': item.file.type || 'application/octet-stream',
|
|
5960
|
+
'X-Attachment-Filename': encodeURIComponent(item.file.name),
|
|
5961
|
+
},
|
|
5962
|
+
body: item.file,
|
|
5963
|
+
});
|
|
5964
|
+
if (result && result.id) ids.push(result.id);
|
|
5965
|
+
}
|
|
5966
|
+
return ids;
|
|
5967
|
+
}
|
|
5968
|
+
|
|
5969
|
+
// R13: URL the transcript renderer points an <img> (or download affordance)
|
|
5970
|
+
// at for one persisted attachment.
|
|
5971
|
+
function attachmentUrlFor(conv, attachmentId) {
|
|
5972
|
+
const scope = convScope(conv);
|
|
5973
|
+
const params = new URLSearchParams();
|
|
5974
|
+
if (scope === 'manager' || scope === 'company') {
|
|
5975
|
+
params.set('scope', scope);
|
|
5976
|
+
} else {
|
|
5977
|
+
params.set('projectPath', (conv && conv.projectPath) || state.projectPath || '');
|
|
5978
|
+
}
|
|
5979
|
+
return `/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/attachments/${encodeURIComponent(attachmentId)}?${params.toString()}`;
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5785
5982
|
function syncSendButton() {
|
|
5786
5983
|
const conv = activeConversation();
|
|
5787
5984
|
const hasText = els['coach-text'].value.trim().length > 0;
|
|
@@ -5801,7 +5998,9 @@ function syncSendButton() {
|
|
|
5801
5998
|
// is enough to enable Send even with an empty textarea — the user just
|
|
5802
5999
|
// wants to fire that coaching directive, optionally with added context.
|
|
5803
6000
|
const hasPendingJob = !!state.pendingCoachingJobId;
|
|
5804
|
-
|
|
6001
|
+
// Issue #1651 R6: a valid staged attachment is enough to enable Send even
|
|
6002
|
+
// with an empty textarea, same as a pending coaching job.
|
|
6003
|
+
els['send'].disabled = !((hasText || hasPendingJob || hasValidStagedAttachment()) && (resumable || canRestartWithCoaching));
|
|
5805
6004
|
}
|
|
5806
6005
|
|
|
5807
6006
|
function defaultCoachNote(conv) {
|
|
@@ -8745,6 +8944,9 @@ async function continueRun(text, options) {
|
|
|
8745
8944
|
// Issue #1176: 'stop' course-corrects the active turn instead of queueing
|
|
8746
8945
|
// behind it; omitted/'after' is the default queue-after-current behavior.
|
|
8747
8946
|
const deliveryIntent = opts.deliveryIntent === 'stop' ? 'stop' : undefined;
|
|
8947
|
+
// Issue #1651 R7: ids already uploaded via uploadStagedAttachments(), resolved
|
|
8948
|
+
// server-side into AiHubMessage.attachments plus a host-bound reference.
|
|
8949
|
+
const attachmentIds = Array.isArray(opts.attachmentIds) && opts.attachmentIds.length > 0 ? opts.attachmentIds : undefined;
|
|
8748
8950
|
if (!opts.preserveReviewApproved) {
|
|
8749
8951
|
conv.reviewApproved = false;
|
|
8750
8952
|
conv.pendingDeliveryActionId = null;
|
|
@@ -8771,7 +8973,13 @@ async function continueRun(text, options) {
|
|
|
8771
8973
|
run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
|
|
8772
8974
|
method: 'POST',
|
|
8773
8975
|
headers: { 'Content-Type': 'application/json' },
|
|
8774
|
-
body: JSON.stringify({
|
|
8976
|
+
body: JSON.stringify({
|
|
8977
|
+
instructions: effectiveText,
|
|
8978
|
+
...(coachingJobId ? { coachingJobId } : {}),
|
|
8979
|
+
...(deliveryIntent ? { deliveryIntent } : {}),
|
|
8980
|
+
...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
|
|
8981
|
+
...(attachmentIds ? { attachmentIds } : {}),
|
|
8982
|
+
}),
|
|
8775
8983
|
});
|
|
8776
8984
|
} catch (e) {
|
|
8777
8985
|
const isNotFound = /not found/i.test((e && e.message) || '');
|
|
@@ -8821,6 +9029,7 @@ async function continueRun(text, options) {
|
|
|
8821
9029
|
instructions: effectiveText,
|
|
8822
9030
|
...(coachingJobId ? { coachingJobId } : {}),
|
|
8823
9031
|
...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
|
|
9032
|
+
...(attachmentIds ? { attachmentIds } : {}),
|
|
8824
9033
|
}),
|
|
8825
9034
|
});
|
|
8826
9035
|
conv.runId = run.id; // bind the conversation to the new run
|
|
@@ -8921,6 +9130,9 @@ function foldRunIntoConversation(conv, run) {
|
|
|
8921
9130
|
if (typeof m.createdAt === 'string') record.createdAt = m.createdAt;
|
|
8922
9131
|
// Issue #1176: carry the submit-time queued/redirecting badge, if any.
|
|
8923
9132
|
if (m.deliveryStatus) record.deliveryStatus = m.deliveryStatus;
|
|
9133
|
+
// Issue #1651: carry attachment metadata, or a reloaded/re-folded conversation
|
|
9134
|
+
// silently loses its attachment chips even though the blobs remain on disk.
|
|
9135
|
+
if (Array.isArray(m.attachments) && m.attachments.length > 0) record.attachments = m.attachments;
|
|
8924
9136
|
const parsed = timestampMillis(m.createdAt);
|
|
8925
9137
|
if (parsed) record.at = parsed;
|
|
8926
9138
|
return record;
|
|
@@ -10094,9 +10306,10 @@ function wireEvents() {
|
|
|
10094
10306
|
}
|
|
10095
10307
|
els['send'].addEventListener('click', async () => {
|
|
10096
10308
|
const text = els['coach-text'].value.trim();
|
|
10097
|
-
// Allow send when textarea is empty IFF a coaching job is pending
|
|
10098
|
-
//
|
|
10099
|
-
|
|
10309
|
+
// Allow send when textarea is empty IFF a coaching job is pending, or a
|
|
10310
|
+
// valid attachment is staged (#1651 R6) — the user just wants to fire
|
|
10311
|
+
// that job, or hand over the attachment(s), with no accompanying text.
|
|
10312
|
+
if (!text && !state.pendingCoachingJobId && !hasValidStagedAttachment()) return;
|
|
10100
10313
|
els['coach-text'].value = '';
|
|
10101
10314
|
syncSendButton();
|
|
10102
10315
|
const conv = activeConversation();
|
|
@@ -10115,11 +10328,26 @@ function wireEvents() {
|
|
|
10115
10328
|
const chosenAgent = normalizeActiveAgentId(
|
|
10116
10329
|
els['active-employee-select'] && els['active-employee-select'].value
|
|
10117
10330
|
);
|
|
10331
|
+
// Issue #1651 R7: upload any staged attachments before the message send so
|
|
10332
|
+
// the ids can ride along on the same request. Uploading is a no-op when
|
|
10333
|
+
// nothing valid is staged.
|
|
10334
|
+
let attachmentIds = [];
|
|
10335
|
+
if (conv && hasValidStagedAttachment()) {
|
|
10336
|
+
try {
|
|
10337
|
+
attachmentIds = await uploadStagedAttachments(conv);
|
|
10338
|
+
} catch (error) {
|
|
10339
|
+
showStatus(error.message, true);
|
|
10340
|
+
}
|
|
10341
|
+
}
|
|
10342
|
+
clearStagedAttachments();
|
|
10118
10343
|
if (conv && chosenAgent && chosenAgent !== normalizeActiveAgentId(conversationAgentName(conv))) {
|
|
10344
|
+
// Known limitation: an agent switch on the same send that carries
|
|
10345
|
+
// attachments uploads the files (they land on disk) but does not yet
|
|
10346
|
+
// thread attachmentIds through restartConvWithAgent's fresh-run message.
|
|
10119
10347
|
state.selectedEmployeeId = chosenAgent;
|
|
10120
10348
|
await restartConvWithAgent(conv, chosenAgent, text);
|
|
10121
10349
|
} else {
|
|
10122
|
-
await continueRun(text, { deliveryIntent });
|
|
10350
|
+
await continueRun(text, { deliveryIntent, attachmentIds });
|
|
10123
10351
|
}
|
|
10124
10352
|
});
|
|
10125
10353
|
|
|
@@ -10133,6 +10361,50 @@ function wireEvents() {
|
|
|
10133
10361
|
});
|
|
10134
10362
|
}
|
|
10135
10363
|
|
|
10364
|
+
// Issue #1651 R1/R2/R3: attach button (file picker), drag-drop onto the
|
|
10365
|
+
// composer, and paste-image support — ported from the validated mock
|
|
10366
|
+
// (docs/feature-specs/mocks/1651-view.html).
|
|
10367
|
+
if (els['attach-btn'] && els['attach-file-input']) {
|
|
10368
|
+
els['attach-btn'].addEventListener('click', () => els['attach-file-input'].click());
|
|
10369
|
+
els['attach-file-input'].addEventListener('change', () => {
|
|
10370
|
+
Array.from(els['attach-file-input'].files || []).forEach(stageAttachmentFile);
|
|
10371
|
+
els['attach-file-input'].value = '';
|
|
10372
|
+
});
|
|
10373
|
+
}
|
|
10374
|
+
if (els['coach']) {
|
|
10375
|
+
['dragenter', 'dragover'].forEach((evt) => {
|
|
10376
|
+
els['coach'].addEventListener(evt, (e) => {
|
|
10377
|
+
e.preventDefault();
|
|
10378
|
+
els['coach'].classList.add('drag-active');
|
|
10379
|
+
});
|
|
10380
|
+
});
|
|
10381
|
+
['dragleave', 'drop'].forEach((evt) => {
|
|
10382
|
+
els['coach'].addEventListener(evt, (e) => {
|
|
10383
|
+
e.preventDefault();
|
|
10384
|
+
els['coach'].classList.remove('drag-active');
|
|
10385
|
+
});
|
|
10386
|
+
});
|
|
10387
|
+
els['coach'].addEventListener('drop', (e) => {
|
|
10388
|
+
Array.from((e.dataTransfer && e.dataTransfer.files) || []).forEach(stageAttachmentFile);
|
|
10389
|
+
});
|
|
10390
|
+
}
|
|
10391
|
+
if (els['coach-text']) {
|
|
10392
|
+
els['coach-text'].addEventListener('paste', (e) => {
|
|
10393
|
+
const items = (e.clipboardData && e.clipboardData.items) || [];
|
|
10394
|
+
Array.from(items).forEach((item) => {
|
|
10395
|
+
if (!item.type || !item.type.startsWith('image/')) return;
|
|
10396
|
+
const file = item.getAsFile();
|
|
10397
|
+
if (!file) return;
|
|
10398
|
+
try {
|
|
10399
|
+
Object.defineProperty(file, 'name', { value: 'pasted-screenshot.png' });
|
|
10400
|
+
} catch {
|
|
10401
|
+
/* some browsers disallow redefining File.name; stage it under its own name */
|
|
10402
|
+
}
|
|
10403
|
+
stageAttachmentFile(file);
|
|
10404
|
+
});
|
|
10405
|
+
});
|
|
10406
|
+
}
|
|
10407
|
+
|
|
10136
10408
|
// Issue #442: keep A/B toggle visible only when the selected employee supports
|
|
10137
10409
|
// direct-path invocation (supportsRaw). Also wire the explanation paragraph.
|
|
10138
10410
|
if (els['employee-select']) {
|
|
@@ -14011,9 +14283,13 @@ function tfResolveAreaView(area, fallbackConv) {
|
|
|
14011
14283
|
}
|
|
14012
14284
|
|
|
14013
14285
|
function tfAreaScopedConversations(area) {
|
|
14286
|
+
// Uses the same stable kickoff-time comparator as renderRail()'s Projects-rail sort
|
|
14287
|
+
// (#1652). Sorting by lastUpdatedAt instead (as this used to) reshuffled the
|
|
14288
|
+
// Manager/Company "In Progress" list on every poll tick, since lastUpdatedAt changes
|
|
14289
|
+
// with every message a running job emits; createdAt pins a run where it was kicked off.
|
|
14014
14290
|
return Object.values(state.conversations || {}).flat()
|
|
14015
14291
|
.filter((conv) => conv && convScope(conv) === area)
|
|
14016
|
-
.sort(
|
|
14292
|
+
.sort(compareByKickoffTime);
|
|
14017
14293
|
}
|
|
14018
14294
|
|
|
14019
14295
|
// Every area has FRAIMworker. Named employees join an area when that area is
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -1987,6 +1987,8 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
1987
1987
|
border: none;
|
|
1988
1988
|
border-radius: 0;
|
|
1989
1989
|
padding: 0;
|
|
1990
|
+
/* Issue #1651: anchors the drag-over dropzone overlay. */
|
|
1991
|
+
position: relative;
|
|
1990
1992
|
}
|
|
1991
1993
|
/* Send button embedded inside the textarea wrapper */
|
|
1992
1994
|
.coach-input {
|
|
@@ -1999,7 +2001,7 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
1999
2001
|
resize: vertical;
|
|
2000
2002
|
border: 1px solid var(--line);
|
|
2001
2003
|
border-radius: 8px;
|
|
2002
|
-
padding: 12px 52px 12px
|
|
2004
|
+
padding: 12px 52px 12px 44px;
|
|
2003
2005
|
font: inherit;
|
|
2004
2006
|
color: var(--text);
|
|
2005
2007
|
background: var(--surface);
|
|
@@ -2022,6 +2024,122 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
2022
2024
|
justify-content: center;
|
|
2023
2025
|
border: none;
|
|
2024
2026
|
}
|
|
2027
|
+
|
|
2028
|
+
/* Issue #1651: attach button sits to the left inside the same textarea
|
|
2029
|
+
wrapper, sized to match #send (32x32, 6px radius). */
|
|
2030
|
+
.attach-btn {
|
|
2031
|
+
position: absolute;
|
|
2032
|
+
bottom: 7px;
|
|
2033
|
+
left: 7px;
|
|
2034
|
+
width: 32px;
|
|
2035
|
+
height: 32px;
|
|
2036
|
+
padding: 0;
|
|
2037
|
+
border-radius: 6px;
|
|
2038
|
+
border: 1px solid var(--line);
|
|
2039
|
+
background: var(--surface);
|
|
2040
|
+
color: var(--muted);
|
|
2041
|
+
font-size: 15px;
|
|
2042
|
+
line-height: 1;
|
|
2043
|
+
display: flex;
|
|
2044
|
+
align-items: center;
|
|
2045
|
+
justify-content: center;
|
|
2046
|
+
cursor: pointer;
|
|
2047
|
+
}
|
|
2048
|
+
.attach-btn:hover { background: var(--soft); color: var(--text); }
|
|
2049
|
+
.attach-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2050
|
+
|
|
2051
|
+
/* Staged attachments: chips shown above the composer before send. Thumbnail
|
|
2052
|
+
sizing/radius matches .brand-logo-drop .blp; remove control matches
|
|
2053
|
+
.srch-clear (bare glyph button). */
|
|
2054
|
+
.staged-attachments {
|
|
2055
|
+
display: flex;
|
|
2056
|
+
flex-wrap: wrap;
|
|
2057
|
+
gap: 8px;
|
|
2058
|
+
margin-bottom: 8px;
|
|
2059
|
+
}
|
|
2060
|
+
.staged-attachments:empty { display: none; }
|
|
2061
|
+
.attachment-chip {
|
|
2062
|
+
position: relative;
|
|
2063
|
+
display: flex;
|
|
2064
|
+
align-items: center;
|
|
2065
|
+
gap: 8px;
|
|
2066
|
+
max-width: 220px;
|
|
2067
|
+
border: 1px solid var(--line);
|
|
2068
|
+
border-radius: 10px;
|
|
2069
|
+
background: var(--surface);
|
|
2070
|
+
box-shadow: var(--shadow);
|
|
2071
|
+
padding: 6px 28px 6px 6px;
|
|
2072
|
+
}
|
|
2073
|
+
.attachment-chip .thumb {
|
|
2074
|
+
width: 36px;
|
|
2075
|
+
height: 36px;
|
|
2076
|
+
border-radius: 8px;
|
|
2077
|
+
box-shadow: 0 0 0 1px var(--line);
|
|
2078
|
+
flex-shrink: 0;
|
|
2079
|
+
overflow: hidden;
|
|
2080
|
+
background: var(--soft);
|
|
2081
|
+
display: flex;
|
|
2082
|
+
align-items: center;
|
|
2083
|
+
justify-content: center;
|
|
2084
|
+
font-size: 15px;
|
|
2085
|
+
}
|
|
2086
|
+
.attachment-chip .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
2087
|
+
.attachment-chip .meta { min-width: 0; }
|
|
2088
|
+
.attachment-chip .fname { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; display: block; }
|
|
2089
|
+
.attachment-chip .fsize { font-size: 11px; color: var(--muted); }
|
|
2090
|
+
.attachment-chip .chip-remove {
|
|
2091
|
+
position: absolute;
|
|
2092
|
+
top: 4px;
|
|
2093
|
+
right: 4px;
|
|
2094
|
+
border: none;
|
|
2095
|
+
background: none;
|
|
2096
|
+
color: var(--muted);
|
|
2097
|
+
cursor: pointer;
|
|
2098
|
+
font-size: 15px;
|
|
2099
|
+
line-height: 1;
|
|
2100
|
+
padding: 2px;
|
|
2101
|
+
}
|
|
2102
|
+
.attachment-chip .chip-remove:hover { color: var(--danger); }
|
|
2103
|
+
.attachment-chip.chip-error { border-color: var(--danger); }
|
|
2104
|
+
.attachment-chip .chip-error-text { font-size: 10.5px; color: var(--danger); }
|
|
2105
|
+
|
|
2106
|
+
/* Drag-over state: dashed accent overlay across the whole coach block, same
|
|
2107
|
+
visual language as .brand-logo-drop. */
|
|
2108
|
+
.composer-dropzone {
|
|
2109
|
+
position: absolute;
|
|
2110
|
+
inset: -6px;
|
|
2111
|
+
display: none;
|
|
2112
|
+
align-items: center;
|
|
2113
|
+
justify-content: center;
|
|
2114
|
+
border: 1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
|
|
2115
|
+
border-radius: 12px;
|
|
2116
|
+
background: var(--accent-soft);
|
|
2117
|
+
color: var(--accent);
|
|
2118
|
+
font-size: 12.5px;
|
|
2119
|
+
font-weight: 650;
|
|
2120
|
+
z-index: 3;
|
|
2121
|
+
pointer-events: none;
|
|
2122
|
+
}
|
|
2123
|
+
.coach.drag-active .composer-dropzone { display: flex; }
|
|
2124
|
+
|
|
2125
|
+
/* Attachment rendered inline inside a sent transcript message (appendMessageDom). */
|
|
2126
|
+
.msg-attachment {
|
|
2127
|
+
display: inline-flex;
|
|
2128
|
+
align-items: center;
|
|
2129
|
+
gap: 6px;
|
|
2130
|
+
margin-top: 6px;
|
|
2131
|
+
margin-right: 6px;
|
|
2132
|
+
padding: 5px 8px 5px 5px;
|
|
2133
|
+
border: 1px solid var(--line);
|
|
2134
|
+
border-radius: 8px;
|
|
2135
|
+
background: var(--surface);
|
|
2136
|
+
font-size: 11.5px;
|
|
2137
|
+
color: var(--text);
|
|
2138
|
+
}
|
|
2139
|
+
.msg-attachment .thumb { width: 26px; height: 26px; border-radius: 6px; overflow: hidden; background: var(--soft); flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
|
2140
|
+
.msg-attachment .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
2141
|
+
|
|
2142
|
+
.composer-hint { font-size: 11px; color: var(--muted); margin-top: 6px; }
|
|
2025
2143
|
.coach-note {
|
|
2026
2144
|
margin-top: 6px;
|
|
2027
2145
|
color: var(--muted);
|