@yeaft/webchat-agent 1.0.589 → 1.0.591
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/local-runtime/server/handlers/client-conversation.js +76 -11
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +105 -105
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +2 -1
- package/yeaft/attachments.js +190 -46
- package/yeaft/conversation/persist.js +3 -1
- package/yeaft/engine.js +39 -4
- package/yeaft/llm/adapter.js +2 -2
- package/yeaft/llm/openai-responses.js +4 -1
- package/yeaft/person/capabilities.js +73 -22
- package/yeaft/person/contracts.js +4 -3
- package/yeaft/person/created-capability-contract.js +80 -0
- package/yeaft/person/repository.js +70 -9
- package/yeaft/person/runtime.js +17 -8
- package/yeaft/person/script-executor.js +88 -0
- package/yeaft/person/script-worker.js +77 -0
- package/yeaft/person/sqlite-repository.js +3 -0
- package/yeaft/person/sqlite-schema.js +17 -0
- package/yeaft/person/sqlite-store.js +44 -6
- package/yeaft/tool-folding/index.js +12 -2
- package/yeaft/tools/registry.js +11 -0
- package/yeaft/tools/view-image.js +5 -4
- package/yeaft/web-bridge.js +12 -8
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
3
|
+
|
|
4
|
+
// Separate WASM interpreter, NOT node:vm. No host functions/modules are bound.
|
|
5
|
+
// Parent termination bounds startup, computation, output extraction and cleanup.
|
|
6
|
+
let runtime, context;
|
|
7
|
+
try {
|
|
8
|
+
const engine = await getQuickJS();
|
|
9
|
+
runtime = engine.newRuntime();
|
|
10
|
+
runtime.setMemoryLimit(16 * 1024 * 1024);
|
|
11
|
+
runtime.setMaxStackSize(256 * 1024);
|
|
12
|
+
const deadline = Date.now() + 200;
|
|
13
|
+
runtime.setInterruptHandler(() => Date.now() > deadline);
|
|
14
|
+
context = runtime.newContext();
|
|
15
|
+
const { code, input } = workerData;
|
|
16
|
+
// Capture intrinsics before executing generated code; Function compiles ONLY a
|
|
17
|
+
// function body and has no access to this validation closure. Neither raw code
|
|
18
|
+
// interpolation nor mutable guest serializers can redefine the output boundary.
|
|
19
|
+
const source = `(() => {
|
|
20
|
+
'use strict';
|
|
21
|
+
const compile = Function, parse = JSON.parse, stringify = JSON.stringify;
|
|
22
|
+
const isArray = Array.isArray, finite = Number.isFinite;
|
|
23
|
+
const proto = Object.getPrototypeOf, objectProto = Object.prototype;
|
|
24
|
+
const descriptor = Object.getOwnPropertyDescriptor, keys = Reflect.ownKeys;
|
|
25
|
+
const create = Object.create, setProto = Object.setPrototypeOf, ErrorType = Error;
|
|
26
|
+
const input = parse(${JSON.stringify(JSON.stringify(input))});
|
|
27
|
+
const fn = compile('input', ${JSON.stringify(`'use strict';\n${code}`)});
|
|
28
|
+
const ancestors = create(null);
|
|
29
|
+
let nodes = 0;
|
|
30
|
+
function snapshot(v, depth) {
|
|
31
|
+
if (++nodes > 8192 || depth > 32) throw new ErrorType('JSON limit');
|
|
32
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') return v;
|
|
33
|
+
if (typeof v === 'number' && finite(v)) return v;
|
|
34
|
+
if (typeof v !== 'object') throw new ErrorType('JSON only');
|
|
35
|
+
for (let i = 0; i < depth; i++) if (ancestors[i] === v) throw new ErrorType('JSON cycle');
|
|
36
|
+
const array = isArray(v), p = proto(v);
|
|
37
|
+
if (!array && p !== objectProto && p !== null) throw new ErrorType('JSON object only');
|
|
38
|
+
const names = keys(v);
|
|
39
|
+
const length = array ? descriptor(v, 'length').value : names.length;
|
|
40
|
+
if (length > 8192 || names.length !== length + (array ? 1 : 0)) throw new ErrorType('JSON keys');
|
|
41
|
+
const out = array ? setProto([], null) : create(null);
|
|
42
|
+
ancestors[depth] = v;
|
|
43
|
+
for (let i = 0; i < length; i++) {
|
|
44
|
+
const key = array ? '' + i : names[i];
|
|
45
|
+
if (typeof key !== 'string') throw new ErrorType('JSON key');
|
|
46
|
+
const d = descriptor(v, key);
|
|
47
|
+
if (!d || !d.enumerable || !descriptor(d, 'value')) throw new ErrorType('JSON data only');
|
|
48
|
+
out[key] = snapshot(d.value, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
delete ancestors[depth];
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const value = snapshot(fn(input), 0);
|
|
54
|
+
// Snapshot has null prototypes and data properties only: no guest getters or
|
|
55
|
+
// inherited toJSON are run during serialization, even after global mutation.
|
|
56
|
+
const json = stringify(value);
|
|
57
|
+
if (typeof json !== 'string' || json.length > 8192) throw new ErrorType('JSON output limit');
|
|
58
|
+
return json;
|
|
59
|
+
})()`;
|
|
60
|
+
const result = context.evalCode(source, 'created-capability.js');
|
|
61
|
+
if (result.error) {
|
|
62
|
+
result.error.dispose();
|
|
63
|
+
parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
|
|
64
|
+
} else {
|
|
65
|
+
const json = context.getString(result.value);
|
|
66
|
+
result.value.dispose();
|
|
67
|
+
if (Buffer.byteLength(json, 'utf8') > 8192) parentPort.postMessage({ ok: false, code: 'SCRIPT_OUTPUT' });
|
|
68
|
+
else parentPort.postMessage({ ok: true, output: JSON.parse(json) });
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Guest error strings may contain arbitrary input; fixed codes only cross out.
|
|
72
|
+
parentPort.postMessage({ ok: false, code: 'SCRIPT_EXECUTION' });
|
|
73
|
+
} finally {
|
|
74
|
+
context?.dispose();
|
|
75
|
+
runtime?.dispose();
|
|
76
|
+
parentPort.close();
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { digest, fail, identifier, PersonError, text } from './contracts.js';
|
|
4
|
+
import { validateCreatedCapability } from './created-capability-contract.js';
|
|
4
5
|
|
|
5
6
|
const storageError = code => {
|
|
6
7
|
const error = new PersonError(code);
|
|
@@ -91,6 +92,8 @@ export class SqlitePersonRepository {
|
|
|
91
92
|
async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
|
|
92
93
|
async startCall(episode, data) { return this.request('startCall', [episode, data]); }
|
|
93
94
|
async finalizeCall(episode, data) { return this.request('finalizeCall', [episode, data]); }
|
|
95
|
+
async createdCapabilities(episode) { return this.request('createdCapabilities', [episode]); }
|
|
96
|
+
async saveCreatedCapability(episode, input) { return this.request('saveCreatedCapability', [episode, validateCreatedCapability(input)]); }
|
|
94
97
|
async context(episode) { return this.request('context', [episode]); }
|
|
95
98
|
async recall(ownerId, options) { return this.request('recall', [ownerId, options]); }
|
|
96
99
|
async commit(episode, proposal, selection, callId, reportedSources = new Map()) { return this.request('commit', [episode, proposal, selection, callId, reportedSources]); }
|
|
@@ -8,6 +8,8 @@ export const TABLES = Object.freeze({
|
|
|
8
8
|
concept_revisions: { keys: ['id', 'revision'], columns: [] },
|
|
9
9
|
state_commits: { keys: ['version'], columns: ['id'] },
|
|
10
10
|
traces: { keys: ['id'], columns: ['seq'] },
|
|
11
|
+
created_capabilities: { keys: ['id'], columns: ['version'] },
|
|
12
|
+
created_capability_revisions: { keys: ['id', 'version'], columns: [] },
|
|
11
13
|
});
|
|
12
14
|
|
|
13
15
|
export const SCHEMA = `
|
|
@@ -66,6 +68,21 @@ CREATE TABLE IF NOT EXISTS traces (
|
|
|
66
68
|
PRIMARY KEY(namespace, ownerId, personId, id), UNIQUE(namespace, ownerId, personId, seq),
|
|
67
69
|
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
68
70
|
) STRICT;
|
|
71
|
+
CREATE INDEX IF NOT EXISTS traces_call_output ON traces(namespace, ownerId, personId,
|
|
72
|
+
json_extract(record, '$.episodeId'), json_extract(record, '$.callId'))
|
|
73
|
+
WHERE json_extract(record, '$.kind') = 'call_output';
|
|
74
|
+
CREATE TABLE IF NOT EXISTS created_capabilities (
|
|
75
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
76
|
+
id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
|
|
77
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id),
|
|
78
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
79
|
+
) STRICT;
|
|
80
|
+
CREATE TABLE IF NOT EXISTS created_capability_revisions (
|
|
81
|
+
namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
|
|
82
|
+
id TEXT NOT NULL, version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 32),
|
|
83
|
+
record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId, id, version),
|
|
84
|
+
FOREIGN KEY(namespace, ownerId, personId) REFERENCES persons(namespace, ownerId, personId)
|
|
85
|
+
) STRICT;
|
|
69
86
|
-- This journal is authority data, not a disposable search index. A committed seq is
|
|
70
87
|
-- never reused, including after reopening; gaps between owners are intentional.
|
|
71
88
|
CREATE TABLE IF NOT EXISTS memory_changes (
|
|
@@ -4,6 +4,7 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { isMainThread } from 'node:worker_threads';
|
|
6
6
|
import { bytes, digest, fail, identifier, LIMITS, PersonError, text } from './contracts.js';
|
|
7
|
+
import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapability } from './created-capability-contract.js';
|
|
7
8
|
import { SCHEMA, TABLES } from './sqlite-schema.js';
|
|
8
9
|
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
9
10
|
|
|
@@ -11,8 +12,20 @@ const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
|
|
|
11
12
|
const scopeValues = s => [s.namespace, s.ownerId, s.personId];
|
|
12
13
|
// Query-only projections; record retains the original public Unicode text.
|
|
13
14
|
const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
|
|
14
|
-
const
|
|
15
|
-
|
|
15
|
+
const metadataDates = Object.freeze({
|
|
16
|
+
persons: ['createdAt', 'leaseUntil'], episodes: ['createdAt', 'endedAt', 'callFinalizeUntil'],
|
|
17
|
+
messages: ['createdAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
|
|
18
|
+
state_commits: ['createdAt'], traces: ['createdAt'],
|
|
19
|
+
});
|
|
20
|
+
const decode = (row, table) => {
|
|
21
|
+
if (!row) return null;
|
|
22
|
+
const record = JSON.parse(row.record);
|
|
23
|
+
// Only known storage metadata is a Date. Trace payloads and created-definition
|
|
24
|
+
// JSON may use these names at any depth without changing their string values.
|
|
25
|
+
for (const key of metadataDates[table] ?? []) if (typeof record[key] === 'string') record[key] = new Date(record[key]);
|
|
26
|
+
if (table === 'state_commits' && typeof record.state?.updatedAt === 'string') record.state.updatedAt = new Date(record.state.updatedAt);
|
|
27
|
+
return record;
|
|
28
|
+
};
|
|
16
29
|
const publicDoc = doc => {
|
|
17
30
|
if (!doc) return null;
|
|
18
31
|
const { _id, ownerId, namespace, personId, ...rest } = doc;
|
|
@@ -32,8 +45,8 @@ const publicOutput = (output, failed) => {
|
|
|
32
45
|
return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
|
|
33
46
|
: { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
|
|
34
47
|
};
|
|
35
|
-
const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories']);
|
|
36
|
-
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot']);
|
|
48
|
+
const READS = new Set(['getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities']);
|
|
49
|
+
const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'commit', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
37
50
|
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
38
51
|
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
39
52
|
const sequence = value => {
|
|
@@ -101,7 +114,8 @@ export class SqlitePersonStore {
|
|
|
101
114
|
// All clauses at call sites below are fixed application strings; values are bound.
|
|
102
115
|
rows(table, scope, clause = '', params = []) {
|
|
103
116
|
this.table(table);
|
|
104
|
-
return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params)
|
|
117
|
+
return this.sql(`SELECT record FROM ${table} WHERE ${SCOPE}${clause}`).all(...scopeValues(scope), ...params)
|
|
118
|
+
.map(row => decode(row, table));
|
|
105
119
|
}
|
|
106
120
|
one(table, scope, clause = '', params = []) { return this.rows(table, scope, `${clause} LIMIT 1`, params)[0] ?? null; }
|
|
107
121
|
put(table, record, insert = false) {
|
|
@@ -220,6 +234,8 @@ export class SqlitePersonStore {
|
|
|
220
234
|
const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
|
|
221
235
|
}
|
|
222
236
|
append(episode, kind, data) {
|
|
237
|
+
// Call proof/publication events are emitted only by their transactional methods.
|
|
238
|
+
if (['call_started', 'call_output', 'call_failed', 'capability_created'].includes(kind)) fail('INVALID_REQUEST');
|
|
223
239
|
const p = this.own(episode);
|
|
224
240
|
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
225
241
|
if (!record || record.status !== 'running') fail('STALE');
|
|
@@ -255,6 +271,28 @@ export class SqlitePersonStore {
|
|
|
255
271
|
});
|
|
256
272
|
return live;
|
|
257
273
|
}
|
|
274
|
+
createdCapabilities(episode) {
|
|
275
|
+
this.own(episode);
|
|
276
|
+
return this.rows('created_capabilities', this.episodeScope(episode), ' ORDER BY id ASC LIMIT 32').map(publicDoc);
|
|
277
|
+
}
|
|
278
|
+
saveCreatedCapability(episode, input) {
|
|
279
|
+
const { definition, evidence, callId } = validateCreatedCapability(input);
|
|
280
|
+
this.own(episode);
|
|
281
|
+
const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
282
|
+
if (!active || active.status !== 'running' || active.epoch !== episode.epoch || active.workerId !== episode.workerId) fail('STALE');
|
|
283
|
+
const proof = this.one('traces', scope, " AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
|
|
284
|
+
if (!proof || active.openCall?.callId === callId) fail('INVALID_REQUEST');
|
|
285
|
+
const current = this.one('created_capabilities', scope, ' AND id = ?', [definition.id]);
|
|
286
|
+
if ((current?.version ?? 0) !== definition.expectedVersion) fail('STALE');
|
|
287
|
+
if (definition.expectedVersion >= CREATED_CAPABILITY_LIMITS.versions || (!current &&
|
|
288
|
+
this.sql(`SELECT count(*) AS n FROM created_capabilities WHERE ${SCOPE}`).get(...scopeValues(scope)).n >= CREATED_CAPABILITY_LIMITS.ids)) fail('CONTEXT_LIMIT');
|
|
289
|
+
const record = this.doc(scope, createdCapabilityRecord(definition, evidence, { episode, callId, now: this.now }));
|
|
290
|
+
this.put('created_capability_revisions', record, true);
|
|
291
|
+
this.put('created_capabilities', record);
|
|
292
|
+
this.trace(episode.ownerId, episode.id, 'capability_created', { callId, capabilityId: record.id,
|
|
293
|
+
capabilityManifest: { id: record.id, version: record.version, revision: record.revision }, evidence: record.evidence });
|
|
294
|
+
return publicDoc(record);
|
|
295
|
+
}
|
|
258
296
|
focused(scope, ids) {
|
|
259
297
|
return ids.map(id => this.one('concepts', scope, ' AND id = ?', [id])).filter(Boolean);
|
|
260
298
|
}
|
|
@@ -366,7 +404,7 @@ export class SqlitePersonStore {
|
|
|
366
404
|
this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);
|
|
367
405
|
const rows = this.sql(`SELECT seq, kind, id, revision, record FROM memory_changes WHERE ${SCOPE} AND seq > ? ORDER BY seq ASC LIMIT ?`)
|
|
368
406
|
.all(...scopeValues(this.scope(ownerId)), after, limit + 1);
|
|
369
|
-
const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row) }));
|
|
407
|
+
const items = rows.slice(0, limit).map(row => ({ ...row, record: decode(row, row.kind) }));
|
|
370
408
|
return { items, lastSeq: items.at(-1)?.seq ?? after, hasMore: rows.length > limit };
|
|
371
409
|
}
|
|
372
410
|
resolveMemories(ownerId, kind, refs) {
|
|
@@ -125,6 +125,16 @@ export function buildDuplicateReminder({ toolName, count, lastResultBrief }) {
|
|
|
125
125
|
+ `Consider whether re-running this tool is necessary or if you should try a different approach.`;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function toolOutputForReflection(content) {
|
|
129
|
+
if (typeof content === 'string') return content;
|
|
130
|
+
if (Array.isArray(content) && content.every(part => part && ['text', 'image', 'document'].includes(part.type))) {
|
|
131
|
+
// Reflection consumes text, not a base64 dump of visual context. The image
|
|
132
|
+
// has already been delivered to the primary model in the paired result.
|
|
133
|
+
return content.map(part => part.type === 'text' ? part.text : `[${part.type} content omitted from reflection]`).join('\n');
|
|
134
|
+
}
|
|
135
|
+
return JSON.stringify(content);
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
/**
|
|
129
139
|
* Convert assistant.toolCalls + matching tool results into the
|
|
130
140
|
* { name, input, output, isError } pairs the reflector prompt expects.
|
|
@@ -159,14 +169,14 @@ export function extractToolPairsFromRange(messages, startIdx, endIdx) {
|
|
|
159
169
|
} else if (m.role === 'tool') {
|
|
160
170
|
const ent = m.toolCallId ? byId.get(m.toolCallId) : null;
|
|
161
171
|
if (ent) {
|
|
162
|
-
ent.output =
|
|
172
|
+
ent.output = toolOutputForReflection(m.content);
|
|
163
173
|
ent.isError = !!m.isError;
|
|
164
174
|
} else {
|
|
165
175
|
// Orphan tool result — still include for completeness.
|
|
166
176
|
pairs.push({
|
|
167
177
|
name: '(orphan)',
|
|
168
178
|
input: {},
|
|
169
|
-
output:
|
|
179
|
+
output: toolOutputForReflection(m.content),
|
|
170
180
|
isError: !!m.isError,
|
|
171
181
|
});
|
|
172
182
|
}
|
package/yeaft/tools/registry.js
CHANGED
|
@@ -238,6 +238,17 @@ function truncateUtf8(text, maxBytes) {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
export function truncateToolResultIfNeeded(output, { toolName, language } = {}) {
|
|
241
|
+
// Provider content parts are not JSON text. Bound their text together while
|
|
242
|
+
// preserving binary blocks; history-window budgets/omits attachments whole.
|
|
243
|
+
// Arbitrary structured tool output still follows the legacy JSON path.
|
|
244
|
+
if (Array.isArray(output) && output.length > 0
|
|
245
|
+
&& output.every(part => part && ['text', 'image', 'document'].includes(part.type))) {
|
|
246
|
+
const text = output.filter(part => part.type === 'text').map(part => part.text || '').join('\n');
|
|
247
|
+
return [
|
|
248
|
+
...(text ? [{ type: 'text', text: truncateToolResultIfNeeded(text, { toolName, language }) }] : []),
|
|
249
|
+
...output.filter(part => part.type !== 'text'),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
241
252
|
const text = normalizeToolOutput(output);
|
|
242
253
|
const originalBytes = Buffer.byteLength(text, 'utf8');
|
|
243
254
|
if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
|
|
@@ -123,8 +123,9 @@ export default defineTool({
|
|
|
123
123
|
en: `Load a local image file and attach it to the conversation so the LLM can see it.
|
|
124
124
|
|
|
125
125
|
Returns a base64 data URI (\`image\` field) plus metadata (format, dimensions,
|
|
126
|
-
size). The
|
|
127
|
-
|
|
126
|
+
size). The engine delivers the image as a native multimodal tool result.
|
|
127
|
+
Inspect the whole image first. Crop/zoom only details that are unreadable; OCR
|
|
128
|
+
is optional for text extraction, not a prerequisite for seeing the image.
|
|
128
129
|
|
|
129
130
|
When to call:
|
|
130
131
|
- User references a local image path (screenshot, design, log/chart) and
|
|
@@ -151,8 +152,8 @@ Max size: 20 MiB by default (configurable via ctx.maxImageBytes).
|
|
|
151
152
|
Path must live under the project directory or an explicit host allowlist.`,
|
|
152
153
|
zh: `加载本地图片文件并附加到对话中,使 LLM 可以查看。
|
|
153
154
|
|
|
154
|
-
返回 base64 data URI(image
|
|
155
|
-
|
|
155
|
+
返回 base64 data URI(image 字段)及元数据(格式、尺寸、大小)。引擎将图片作为原生多模态工具结果交给模型。
|
|
156
|
+
先直接查看整图;仅对确实看不清的细节裁剪/放大。OCR 用于需要时提取文字,不是看图的前置步骤。
|
|
156
157
|
|
|
157
158
|
何时调用:
|
|
158
159
|
- 用户引用本地图片路径(截图、设计稿、日志/图表)并要求你读取、分析或描述它。
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -1454,12 +1454,15 @@ function projectPersistedToVisibleHistoryEntry(m) {
|
|
|
1454
1454
|
return entry && (entry.role === 'user' || entry.role === 'assistant') ? entry : null;
|
|
1455
1455
|
}
|
|
1456
1456
|
|
|
1457
|
-
function hydrateHistoryAttachmentPreviews(attachments) {
|
|
1457
|
+
function hydrateHistoryAttachmentPreviews(attachments, sessionId) {
|
|
1458
1458
|
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
1459
1459
|
return attachments.map((att) => {
|
|
1460
1460
|
if (!att || typeof att !== 'object') return att;
|
|
1461
1461
|
if (!att.isImage || att.preview || att.previewData) return att;
|
|
1462
|
-
const payload = persistedAttachmentPreviewPayload(att
|
|
1462
|
+
const payload = sessionId ? persistedAttachmentPreviewPayload(att, {
|
|
1463
|
+
yeaftDir: session?.yeaftDir || ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR,
|
|
1464
|
+
sessionId,
|
|
1465
|
+
}) : null;
|
|
1463
1466
|
return payload ? { ...att, previewData: payload } : att;
|
|
1464
1467
|
});
|
|
1465
1468
|
}
|
|
@@ -1538,7 +1541,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
|
|
|
1538
1541
|
threadId: m.threadId || m.turnId || 'main',
|
|
1539
1542
|
...(m.turnId ? { turnId: m.turnId } : {}),
|
|
1540
1543
|
...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
|
|
1541
|
-
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
|
|
1544
|
+
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments, m.sessionId) } : {}),
|
|
1542
1545
|
...(m.quote ? { quote: m.quote } : {}),
|
|
1543
1546
|
...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
|
|
1544
1547
|
...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
|
|
@@ -1596,7 +1599,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
|
|
|
1596
1599
|
message: {
|
|
1597
1600
|
content: entry.content,
|
|
1598
1601
|
id: entry.id || null,
|
|
1599
|
-
...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
|
|
1602
|
+
...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments, entry.sessionId) } : {}),
|
|
1600
1603
|
...(entry.quote ? { quote: entry.quote } : {}),
|
|
1601
1604
|
},
|
|
1602
1605
|
ts: entry.ts || null,
|
|
@@ -4920,9 +4923,9 @@ async function runYeaftSessionSend(msg) {
|
|
|
4920
4923
|
// ── Attachments (images + files) ───────────────────────────────
|
|
4921
4924
|
// Server has already resolved fileId → { name, mimeType, data:base64,
|
|
4922
4925
|
// isImage } via the client-conversation.js relay
|
|
4923
|
-
// for `yeaft_*`).
|
|
4924
|
-
//
|
|
4925
|
-
//
|
|
4926
|
+
// for `yeaft_*`). Session files belong to the instance data root, not
|
|
4927
|
+
// the agent CWD or project workDir. Absolute controlled references work
|
|
4928
|
+
// with file-tools; per-image content blocks go directly to the model. The
|
|
4926
4929
|
// resolved metadata WITHOUT base64 rides on coord.ingest meta so it
|
|
4927
4930
|
// shows up in the persisted group log and on the envelope every VP
|
|
4928
4931
|
// driver receives.
|
|
@@ -4931,9 +4934,10 @@ async function runYeaftSessionSend(msg) {
|
|
|
4931
4934
|
const attachmentsStart = perfNowMs();
|
|
4932
4935
|
if (inboundFiles.length > 0) {
|
|
4933
4936
|
try {
|
|
4934
|
-
attachmentBundle = persistYeaftAttachments(inboundFiles, {
|
|
4937
|
+
attachmentBundle = persistYeaftAttachments(inboundFiles, { yeaftDir, sessionId });
|
|
4935
4938
|
} catch (err) {
|
|
4936
4939
|
console.warn('[Yeaft] yeaft_session_chat: attachment persist failed', err?.message || err);
|
|
4940
|
+
attachmentBundle.failed = inboundFiles.map(file => ({ name: file?.name || '<unknown>', error: err?.message || String(err) }));
|
|
4937
4941
|
}
|
|
4938
4942
|
}
|
|
4939
4943
|
// Surface partial / total upload failures to the user. We don't abort
|