@tangleai/assistant 0.21.1
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +56 -0
- package/src/actions.d.ts +237 -0
- package/src/actions.js +145 -0
- package/src/component.d.ts +55 -0
- package/src/component.js +93 -0
- package/src/controller.d.ts +36 -0
- package/src/controller.js +322 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/settings.d.ts +6 -0
- package/src/settings.js +11 -0
- package/src/state.d.ts +32 -0
- package/src/state.js +40 -0
- package/src/viewmodel.d.ts +55 -0
- package/src/viewmodel.js +77 -0
- package/src/views.d.ts +332 -0
- package/src/views.js +208 -0
- package/styles/assistant.css +115 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's impure effects: the streaming agent turn, the
|
|
3
|
+
* settings persistence, the transcript persistence and the ledger. The
|
|
4
|
+
* injected `aiFetch` keeps the whole thing testable against a scripted
|
|
5
|
+
* transport, and the injected `ledger` keeps it testable without a
|
|
6
|
+
* store.
|
|
7
|
+
* @param {{ toolbox: any, getApp: () => any,
|
|
8
|
+
* aiFetch?: typeof fetch,
|
|
9
|
+
* aiStorage: { read: () => any, write: (data: any) => any },
|
|
10
|
+
* aiChat: { read: () => any, write: (data: any) => any },
|
|
11
|
+
* ledger: any, system?: string, headers?: Record<string, string>, onDispose?: () => any }} deps
|
|
12
|
+
*/
|
|
13
|
+
export function createAssistantController(deps: {
|
|
14
|
+
toolbox: any;
|
|
15
|
+
getApp: () => any;
|
|
16
|
+
aiFetch?: typeof fetch;
|
|
17
|
+
aiStorage: {
|
|
18
|
+
read: () => any;
|
|
19
|
+
write: (data: any) => any;
|
|
20
|
+
};
|
|
21
|
+
aiChat: {
|
|
22
|
+
read: () => any;
|
|
23
|
+
write: (data: any) => any;
|
|
24
|
+
};
|
|
25
|
+
ledger: any;
|
|
26
|
+
system?: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
onDispose?: () => any;
|
|
29
|
+
}): {
|
|
30
|
+
effects: {
|
|
31
|
+
[k: string]: (props: any, dispatch: any) => void | Promise<any>;
|
|
32
|
+
};
|
|
33
|
+
cancel: () => void;
|
|
34
|
+
dispose: () => any;
|
|
35
|
+
readonly signal: AbortSignal;
|
|
36
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { createChatClient, probeProvider } from '@tangleai/models';
|
|
3
|
+
import { createAgent, createRefiner } from '@tangleai/agents';
|
|
4
|
+
import { createEnvironment } from '@tangleai/context';
|
|
5
|
+
import { applyJSONPatch, compileJsonQuery } from '@jarenjs/json';
|
|
6
|
+
import { isConfigured } from './settings.js';
|
|
7
|
+
/** Chat turns sent back to the model per request (persisted transcripts can be long). */
|
|
8
|
+
const HISTORY_WINDOW = 20;
|
|
9
|
+
|
|
10
|
+
/** Chat turns kept in the persisted transcript (a localStorage slot, not an archive). */
|
|
11
|
+
const SAVED_WINDOW = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How many memories the agent carries into a turn. Small on purpose:
|
|
15
|
+
* they are paid for out of the same history budget the conversation is,
|
|
16
|
+
* and a retrieval that crowded out the conversation would be a worse
|
|
17
|
+
* assistant with a better memory.
|
|
18
|
+
*/
|
|
19
|
+
const MEMORY_WINDOW = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The ledger as the panel shows it: the active objective (a superseded
|
|
23
|
+
* or abandoned one is not "what we are doing" and is not shown), what
|
|
24
|
+
* has been recorded against it, and how many dropped rounds are sitting
|
|
25
|
+
* in slots waiting for a `recall`. One read, so the panel can never
|
|
26
|
+
* disagree with itself about which turn it is describing.
|
|
27
|
+
* @param {any} ledger
|
|
28
|
+
*/
|
|
29
|
+
async function readLedger(ledger) {
|
|
30
|
+
const goal = await ledger.getGoal();
|
|
31
|
+
const slots = await ledger.listSlots();
|
|
32
|
+
return {
|
|
33
|
+
goal: goal !== null && goal.status === 'active'
|
|
34
|
+
? { objective: goal.objective, progress: goal.progress, checkpoint: goal.checkpoint }
|
|
35
|
+
: null,
|
|
36
|
+
memories: (await ledger.listMemories()).length,
|
|
37
|
+
archived: slots.filter((slot) => slot.kind === 'agent-round').length,
|
|
38
|
+
persistence: ledger.storageStatus?.() ?? null,
|
|
39
|
+
retention: await ledger.retentionReport?.() ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The assistant's impure effects: the streaming agent turn, the
|
|
45
|
+
* settings persistence, the transcript persistence and the ledger. The
|
|
46
|
+
* injected `aiFetch` keeps the whole thing testable against a scripted
|
|
47
|
+
* transport, and the injected `ledger` keeps it testable without a
|
|
48
|
+
* store.
|
|
49
|
+
* @param {{ toolbox: any, getApp: () => any,
|
|
50
|
+
* aiFetch?: typeof fetch,
|
|
51
|
+
* aiStorage: { read: () => any, write: (data: any) => any },
|
|
52
|
+
* aiChat: { read: () => any, write: (data: any) => any },
|
|
53
|
+
* ledger: any, system?: string, headers?: Record<string, string>, onDispose?: () => any }} deps
|
|
54
|
+
*/
|
|
55
|
+
export function createAssistantController(deps) {
|
|
56
|
+
let disposed = false, epoch = 0, closing;
|
|
57
|
+
/** @type {AbortController|null} */
|
|
58
|
+
let turnAbort = null;
|
|
59
|
+
const lifetime = new AbortController();
|
|
60
|
+
const fetchImpl = deps.aiFetch ?? globalThis.fetch;
|
|
61
|
+
deps = { ...deps, aiFetch: (url, init = {}) => fetchImpl(url, { ...init,
|
|
62
|
+
signal: AbortSignal.any([lifetime.signal, ...(init.signal ? [init.signal] : [])]) }) };
|
|
63
|
+
function cancel() { epoch++; turnAbort?.abort(); turnAbort = null; }
|
|
64
|
+
function dispose() {
|
|
65
|
+
if (disposed) return closing;
|
|
66
|
+
disposed = true; cancel(); lifetime.abort();
|
|
67
|
+
closing = Promise.resolve().then(() => deps.onDispose?.());
|
|
68
|
+
return closing;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The last completed turn, kept here rather than in the state: it is
|
|
72
|
+
* the trajectory a refinement reads, it is large, and nothing renders
|
|
73
|
+
* it. State holds what the panel draws; this holds what the next
|
|
74
|
+
* action needs.
|
|
75
|
+
* @type {{ messages: any[], steps: any[] } | null}
|
|
76
|
+
*/
|
|
77
|
+
let lastRun = null;
|
|
78
|
+
|
|
79
|
+
/** The client the current settings describe, or a dispatched failure. */
|
|
80
|
+
const clientFor = (state, dispatch) => {
|
|
81
|
+
const s = state.ai.settings;
|
|
82
|
+
try {
|
|
83
|
+
return createChatClient({
|
|
84
|
+
provider: s.provider,
|
|
85
|
+
baseUrl: s.baseUrl,
|
|
86
|
+
apiKey: s.apiKey,
|
|
87
|
+
model: s.model,
|
|
88
|
+
fetch: deps.aiFetch,
|
|
89
|
+
headers: deps.headers,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
dispatch('ai/failed', /** @type {Error} */ (err).message);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const rawEffects = {
|
|
99
|
+
'ai-send': (props, dispatch) => {
|
|
100
|
+
const app = deps.getApp();
|
|
101
|
+
const state = app.getState();
|
|
102
|
+
const draft = state.ai.draft.trim();
|
|
103
|
+
if (draft === '' || state.ai.status === 'streaming') return;
|
|
104
|
+
if (!isConfigured(state.ai.settings)) {
|
|
105
|
+
dispatch('ai/failed', 'Add a provider, model and (for OpenRouter) an API key in settings first.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const runEpoch = epoch;
|
|
109
|
+
dispatch('ai/user', draft);
|
|
110
|
+
|
|
111
|
+
const client = clientFor(state, dispatch);
|
|
112
|
+
if (client === null) return;
|
|
113
|
+
|
|
114
|
+
// weak local models are first-class: enough rounds to read an
|
|
115
|
+
// engine's { error } result, fetch an example and try again —
|
|
116
|
+
// and a studio flow (template → write → patch → repair → save)
|
|
117
|
+
// legitimately runs past a dozen rounds on a small model. The
|
|
118
|
+
// history budget keeps those long sessions inside a small local
|
|
119
|
+
// context window (~6k tokens), which is what makes the higher
|
|
120
|
+
// cap affordable.
|
|
121
|
+
//
|
|
122
|
+
// With the ledger under it, that budget stops destroying: every
|
|
123
|
+
// round it drops is archived to a slot first and the model can
|
|
124
|
+
// `recall` it back. The same ledger puts the objective and what
|
|
125
|
+
// has been learned into the prompt of every turn.
|
|
126
|
+
// and with an ENVIRONMENT over that same ledger, the archive stops
|
|
127
|
+
// being a list of addresses to fetch one at a time: `env_grep`
|
|
128
|
+
// scans every archived round for a pattern and answers with which
|
|
129
|
+
// slot matched and one line of context, so "what did we try
|
|
130
|
+
// earlier?" costs one call instead of one call per round. The
|
|
131
|
+
// corpus and the archive share a store on purpose — the same
|
|
132
|
+
// operations reach both.
|
|
133
|
+
const environment = createEnvironment({
|
|
134
|
+
ledger: deps.ledger,
|
|
135
|
+
compileQuery: compileJsonQuery,
|
|
136
|
+
});
|
|
137
|
+
const agent = createAgent({
|
|
138
|
+
client, toolbox: { toFunctionTools: () => deps.toolbox.toFunctionTools(),
|
|
139
|
+
execute: (name, args) => disposed || epoch !== runEpoch
|
|
140
|
+
? { error: 'The assistant turn was cancelled.' } : deps.toolbox.execute(name, args) }, system: deps.system ?? 'Help the user complete their task using the available tools.', maxToolRounds: 16,
|
|
141
|
+
historyBudget: 24_000,
|
|
142
|
+
ledger: deps.ledger,
|
|
143
|
+
environment,
|
|
144
|
+
retrieval: { memories: { limit: MEMORY_WINDOW } },
|
|
145
|
+
});
|
|
146
|
+
// build the turn from this effect's own snapshot: the ai/user
|
|
147
|
+
// dispatch above is queued FIFO behind the running transaction,
|
|
148
|
+
// so a getState() here would still miss the draft
|
|
149
|
+
const history = [...state.ai.messages, { role: 'user', content: draft }]
|
|
150
|
+
.slice(-HISTORY_WINDOW)
|
|
151
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
152
|
+
turnAbort?.abort(); turnAbort = new AbortController();
|
|
153
|
+
return agent.send(history, {
|
|
154
|
+
signal: turnAbort.signal,
|
|
155
|
+
onDelta: (text) => dispatch('ai/delta', text),
|
|
156
|
+
onReasoning: (text) => dispatch('ai/reasoning', text.length),
|
|
157
|
+
onToolCall: (call) => dispatch('ai/activity', call.name),
|
|
158
|
+
// back to 'Thinking…' between a tool's result and the next token
|
|
159
|
+
onToolResult: () => dispatch('ai/activity', null),
|
|
160
|
+
}).then(
|
|
161
|
+
// an empty final message is a model quirk worth an honest line —
|
|
162
|
+
// and a reasoning-only turn deserves to say what happened
|
|
163
|
+
(result) => {
|
|
164
|
+
if (disposed || epoch !== runEpoch) return;
|
|
165
|
+
lastRun = { messages: result.messages, steps: result.steps };
|
|
166
|
+
dispatch('ai/reply', result.message.content !== ''
|
|
167
|
+
? result.message.content
|
|
168
|
+
: result.message.reasoning !== undefined
|
|
169
|
+
? '*The model spent the whole turn reasoning without a final reply — send another message to continue.*'
|
|
170
|
+
: '*The model ended its turn without a reply — whatever it loaded is on screen; send another message to continue.*');
|
|
171
|
+
// the archive grows during a turn, so the panel's count is read
|
|
172
|
+
// after it: what the model can still reach is a fact about the
|
|
173
|
+
// finished turn, not the one that started it
|
|
174
|
+
readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view), () => {});
|
|
175
|
+
},
|
|
176
|
+
(err) => dispatch('ai/failed', err?.message ?? String(err)),
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
// the ledger panel: one read, dispatched as one value. Run on open
|
|
181
|
+
// (so a reloaded page shows the objective it was left with) and
|
|
182
|
+
// after anything that writes.
|
|
183
|
+
'ai-ledger-read': (props, dispatch) => {
|
|
184
|
+
readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view),
|
|
185
|
+
(err) => dispatch('ai/failed', err?.message ?? String(err)));
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// clearing the conversation clears what compaction archived FROM it:
|
|
189
|
+
// an archived round is a piece of a transcript, and its address only
|
|
190
|
+
// ever appeared in that transcript's synopsis. Keeping the rounds
|
|
191
|
+
// would leave the panel counting recoverable context for a
|
|
192
|
+
// conversation that no longer exists — and leave bytes in the store
|
|
193
|
+
// that nothing can ever name again. Memories and the goal survive:
|
|
194
|
+
// they are what was LEARNED, not what was said.
|
|
195
|
+
'ai-clear-archive': (props, dispatch) => {
|
|
196
|
+
(typeof deps.ledger.clearArchives === 'function' ? deps.ledger.clearArchives()
|
|
197
|
+
: deps.ledger.listSlots().then((slots) => Promise.all(slots
|
|
198
|
+
.filter((slot) => slot.kind === 'agent-round' || slot.kind === 'agent-round-index')
|
|
199
|
+
.map((slot) => deps.ledger.deleteSlot(slot.name)))))
|
|
200
|
+
.then(() => readLedger(deps.ledger))
|
|
201
|
+
.then((view) => dispatch('ai/ledger', view),
|
|
202
|
+
(err) => dispatch('ai/failed', err?.message ?? String(err)));
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
'ai-goal-set': (props, dispatch) => {
|
|
206
|
+
const objective = deps.getApp().getState().ai.goalDraft.trim();
|
|
207
|
+
if (objective === '') return;
|
|
208
|
+
deps.ledger.setGoal({ objective }).then((goal) => {
|
|
209
|
+
if (goal?.error !== undefined) {
|
|
210
|
+
dispatch('ai/failed', goal.error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// the draft is cleared by what comes back from the ledger, not by
|
|
214
|
+
// the action: the objective on screen is the stored one
|
|
215
|
+
return readLedger(deps.ledger).then((view) => dispatch('ai/goal-committed', view));
|
|
216
|
+
}, (err) => dispatch('ai/failed', err?.message ?? String(err)));
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// "clear" abandons the objective rather than deleting it: the ledger
|
|
220
|
+
// keeps what this agent was asked to do, and the panel stops showing
|
|
221
|
+
// an objective nobody is working on
|
|
222
|
+
'ai-goal-clear': (props, dispatch) => {
|
|
223
|
+
deps.ledger.setGoalStatus('abandoned')
|
|
224
|
+
.then(() => readLedger(deps.ledger))
|
|
225
|
+
.then((view) => dispatch('ai/ledger', view),
|
|
226
|
+
(err) => dispatch('ai/failed', err?.message ?? String(err)));
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
// the refinement button: the model proposes an RFC 6902 patch over
|
|
230
|
+
// its own supplemental state, every stage of the gate runs, and what
|
|
231
|
+
// survives is committed. The patch engine is INJECTED here, exactly
|
|
232
|
+
// as @tangleai/agents requires — the package never imports @jarenjs/json.
|
|
233
|
+
'ai-remember': (props, dispatch) => {
|
|
234
|
+
const state = deps.getApp().getState();
|
|
235
|
+
if (lastRun === null) {
|
|
236
|
+
dispatch('ai/remembered', { note: 'Nothing to remember yet — send a message first.' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!isConfigured(state.ai.settings)) {
|
|
240
|
+
dispatch('ai/remembered', { note: 'Add a provider, model and key in settings first.' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const client = clientFor(state, dispatch);
|
|
244
|
+
if (client === null) return;
|
|
245
|
+
return createRefiner({
|
|
246
|
+
client,
|
|
247
|
+
ledger: deps.ledger,
|
|
248
|
+
applyPatch: (document, patch) => applyJSONPatch(document, patch),
|
|
249
|
+
}).refine(lastRun, { signal: lifetime.signal }).then((outcome) => {
|
|
250
|
+
const written = outcome.ok === true
|
|
251
|
+
? outcome.memories.length + outcome.skills.length + outcome.progress.length
|
|
252
|
+
: 0;
|
|
253
|
+
dispatch('ai/remembered', {
|
|
254
|
+
note: outcome.ok !== true
|
|
255
|
+
? `Nothing was stored — ${outcome.error}`
|
|
256
|
+
: written === 0
|
|
257
|
+
? 'The assistant found nothing worth remembering from this session.'
|
|
258
|
+
: `Remembered ${written} evidenced item${written === 1 ? '' : 's'}.`,
|
|
259
|
+
});
|
|
260
|
+
return readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view));
|
|
261
|
+
}, (err) => dispatch('ai/remembered', { note: err?.message ?? String(err) }));
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
'ai-save-settings': async (_props, dispatch) => {
|
|
265
|
+
try {
|
|
266
|
+
if (await deps.aiStorage.write(deps.getApp().getState().ai.settings) === false)
|
|
267
|
+
throw new Error('Assistant settings could not be saved.');
|
|
268
|
+
}
|
|
269
|
+
catch (error) { dispatch('ai/settings-open', true); dispatch('ai/failed', error.message); }
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// the settings "Test connection" button: one /models probe with the
|
|
273
|
+
// exact auth a chat turn would use; the result object drives the
|
|
274
|
+
// status line and the model-name datalist
|
|
275
|
+
'ai-probe': (props, dispatch) => {
|
|
276
|
+
const s = deps.getApp().getState().ai.settings;
|
|
277
|
+
return probeProvider({
|
|
278
|
+
provider: s.provider,
|
|
279
|
+
baseUrl: s.baseUrl,
|
|
280
|
+
apiKey: s.apiKey,
|
|
281
|
+
fetch: deps.aiFetch,
|
|
282
|
+
}).then((result) => dispatch('ai/probe-result', result.ok
|
|
283
|
+
? {
|
|
284
|
+
status: 'ok',
|
|
285
|
+
detail: `Connected — ${result.models.length} model${result.models.length === 1 ? '' : 's'} available.`,
|
|
286
|
+
models: result.models.slice(0, 100),
|
|
287
|
+
}
|
|
288
|
+
: { status: 'fail', detail: result.error, models: [] }));
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// opening the panel unconfigured lands you in settings — pinned
|
|
292
|
+
// open, so the form does not hide the moment typing a model name
|
|
293
|
+
// makes the configuration valid (only Save closes and persists it)
|
|
294
|
+
'ai-ensure-settings': (props, dispatch) => {
|
|
295
|
+
const state = deps.getApp().getState();
|
|
296
|
+
if (state.ai.open && !isConfigured(state.ai.settings)) {
|
|
297
|
+
dispatch('ai/settings-open', true);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
// the transcript mirror: every appended turn (and a clear) writes
|
|
302
|
+
// the visible messages through the injected store, so a reload
|
|
303
|
+
// resumes the conversation
|
|
304
|
+
'ai-persist': async (_props, dispatch) => {
|
|
305
|
+
const messages = deps.getApp().getState().ai.messages;
|
|
306
|
+
try {
|
|
307
|
+
if (await deps.aiChat.write({ messages: messages.slice(-SAVED_WINDOW) }) === false)
|
|
308
|
+
throw new Error('The assistant transcript could not be saved.');
|
|
309
|
+
}
|
|
310
|
+
catch (error) { dispatch('ai/failed', error.message); }
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const effects = Object.fromEntries(Object.entries(rawEffects).map(([name, effect]) => [name, (props, dispatch) => {
|
|
314
|
+
if (disposed) return;
|
|
315
|
+
const version = epoch;
|
|
316
|
+
const publish = (action, payload) => { if (!disposed && version === epoch) dispatch(action, payload); };
|
|
317
|
+
return effect(props, publish);
|
|
318
|
+
}]));
|
|
319
|
+
effects['ai-cancel'] = () => { if (!disposed) cancel(); };
|
|
320
|
+
effects['ai-clear-run'] = () => { if (!disposed) { cancel(); lastRun = null; } };
|
|
321
|
+
return { effects, cancel, dispose, get signal() { return turnAbort?.signal ?? lifetime.signal; } };
|
|
322
|
+
}
|
package/src/index.d.ts
ADDED
package/src/index.js
ADDED
package/src/settings.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { PROVIDERS } from '@tangleai/models/providers';
|
|
3
|
+
export const PROVIDER_OPTIONS = Object.entries(PROVIDERS)
|
|
4
|
+
.map(([value, preset]) => ({ value, label: preset.label, local: preset.local }));
|
|
5
|
+
|
|
6
|
+
export function isConfigured(s) {
|
|
7
|
+
if (s.model.trim() === '') return false;
|
|
8
|
+
if (s.provider === 'custom') return s.baseUrl.trim() !== '';
|
|
9
|
+
if (s.provider === 'openrouter') return s.apiKey.trim() !== '';
|
|
10
|
+
return true; // local runtimes (Ollama, LM Studio) need no key
|
|
11
|
+
}
|
package/src/state.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** @param {any} [aiSettings] @param {any} [aiChat] */
|
|
2
|
+
export function createAssistantState(aiSettings?: any, aiChat?: any): {
|
|
3
|
+
open: boolean;
|
|
4
|
+
settingsOpen: boolean;
|
|
5
|
+
probe: {
|
|
6
|
+
status: string;
|
|
7
|
+
detail: any;
|
|
8
|
+
models: any[];
|
|
9
|
+
};
|
|
10
|
+
reasoningChars: number;
|
|
11
|
+
settings: any;
|
|
12
|
+
messages: any;
|
|
13
|
+
draft: string;
|
|
14
|
+
pending: string;
|
|
15
|
+
status: string;
|
|
16
|
+
activity: any;
|
|
17
|
+
error: any;
|
|
18
|
+
goal: any;
|
|
19
|
+
goalDraft: string;
|
|
20
|
+
memories: number;
|
|
21
|
+
persistence: any;
|
|
22
|
+
retention: any;
|
|
23
|
+
archived: number;
|
|
24
|
+
remembering: boolean;
|
|
25
|
+
remembered: any;
|
|
26
|
+
};
|
|
27
|
+
export namespace DEFAULT_AI_SETTINGS {
|
|
28
|
+
let provider: string;
|
|
29
|
+
let baseUrl: string;
|
|
30
|
+
let model: string;
|
|
31
|
+
let apiKey: string;
|
|
32
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
export const DEFAULT_AI_SETTINGS = {
|
|
3
|
+
provider: 'openrouter', // 'openrouter' | 'ollama' | 'lmstudio' | 'custom'
|
|
4
|
+
baseUrl: '', // required for 'custom'; overrides the preset otherwise
|
|
5
|
+
model: '', // e.g. 'qwen/qwen3-4b' or a local model name
|
|
6
|
+
apiKey: '', // bring your own; local runtimes need none
|
|
7
|
+
};
|
|
8
|
+
/** @param {any} [aiSettings] @param {any} [aiChat] */
|
|
9
|
+
export function createAssistantState(aiSettings = null, aiChat = null) {
|
|
10
|
+
return {
|
|
11
|
+
open: false,
|
|
12
|
+
settingsOpen: false,
|
|
13
|
+
// the settings "Test connection" probe: idle | busy | ok | fail,
|
|
14
|
+
// a human detail line, and the model ids a successful probe found
|
|
15
|
+
probe: { status: 'idle', detail: null, models: [] },
|
|
16
|
+
// reasoning characters streamed this turn (thinking models emit
|
|
17
|
+
// reasoning before - or instead of - visible content)
|
|
18
|
+
reasoningChars: 0,
|
|
19
|
+
settings: { ...DEFAULT_AI_SETTINGS, ...(aiSettings ?? {}) },
|
|
20
|
+
// visible transcript: { role, content }; restored from local
|
|
21
|
+
// storage so a page reload keeps the conversation
|
|
22
|
+
messages: Array.isArray(aiChat?.messages) ? structuredClone(aiChat.messages) : [],
|
|
23
|
+
draft: '', // composer text
|
|
24
|
+
pending: '', // the assistant reply currently streaming
|
|
25
|
+
status: 'idle', // 'idle' | 'streaming' | 'error'
|
|
26
|
+
activity: null, // the tool the model is currently calling
|
|
27
|
+
error: null,
|
|
28
|
+
// the @tangleai/context ledger, as the panel shows it. Read from the
|
|
29
|
+
// ledger (not mirrored into it): the objective is durable state and
|
|
30
|
+
// this slice is a view of it, so a reload shows what storage holds
|
|
31
|
+
// rather than what this tab last typed.
|
|
32
|
+
goal: null, // { objective, progress: [ { at, note, evidence } ] }
|
|
33
|
+
goalDraft: '', // the objective composer
|
|
34
|
+
memories: 0, // evidenced facts carried into every turn
|
|
35
|
+
persistence: null, retention: null,
|
|
36
|
+
archived: 0, // dropped rounds sitting in slots, recallable
|
|
37
|
+
remembering: false, // a refinement is in flight
|
|
38
|
+
remembered: null, // what the last refinement did, in one line
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @param {any} ai @param {(source: string) => any} renderMarkdown */
|
|
2
|
+
export function assistantView(ai: any, renderMarkdown: (source: string) => any): {
|
|
3
|
+
open: any;
|
|
4
|
+
status: any;
|
|
5
|
+
activity: any;
|
|
6
|
+
error: any;
|
|
7
|
+
draft: any;
|
|
8
|
+
configured: boolean;
|
|
9
|
+
showSettings: any;
|
|
10
|
+
settings: {
|
|
11
|
+
provider: any;
|
|
12
|
+
baseUrl: any;
|
|
13
|
+
model: any;
|
|
14
|
+
apiKey: any;
|
|
15
|
+
needsKey: boolean;
|
|
16
|
+
probe: {
|
|
17
|
+
status: any;
|
|
18
|
+
detail: any;
|
|
19
|
+
busy: boolean;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
fail: boolean;
|
|
22
|
+
models: any;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
providers: {
|
|
26
|
+
selected: boolean;
|
|
27
|
+
value: string;
|
|
28
|
+
label: string;
|
|
29
|
+
local: boolean;
|
|
30
|
+
}[];
|
|
31
|
+
empty: boolean;
|
|
32
|
+
messages: any;
|
|
33
|
+
streaming: boolean;
|
|
34
|
+
pending: any;
|
|
35
|
+
thinkingLabel: string;
|
|
36
|
+
goal: {
|
|
37
|
+
objective: any;
|
|
38
|
+
entries: any;
|
|
39
|
+
checkpointLabel: string;
|
|
40
|
+
progress: {
|
|
41
|
+
note: any;
|
|
42
|
+
evidence: any;
|
|
43
|
+
}[];
|
|
44
|
+
more: number;
|
|
45
|
+
};
|
|
46
|
+
goalDraft: any;
|
|
47
|
+
memories: any;
|
|
48
|
+
memoryLabel: string;
|
|
49
|
+
persistenceLabel: string;
|
|
50
|
+
retentionLabel: string;
|
|
51
|
+
archived: any;
|
|
52
|
+
archivedLabel: string;
|
|
53
|
+
remembering: any;
|
|
54
|
+
remembered: any;
|
|
55
|
+
};
|
package/src/viewmodel.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
import { PROVIDER_OPTIONS, isConfigured } from './settings.js';
|
|
3
|
+
/** @param {any} ai @param {(source: string) => any} renderMarkdown */
|
|
4
|
+
export function assistantView(ai, renderMarkdown) {
|
|
5
|
+
const s = ai.settings;
|
|
6
|
+
const configured = isConfigured(s);
|
|
7
|
+
return {
|
|
8
|
+
open: ai.open,
|
|
9
|
+
status: ai.status,
|
|
10
|
+
activity: ai.activity,
|
|
11
|
+
error: ai.error,
|
|
12
|
+
draft: ai.draft,
|
|
13
|
+
configured,
|
|
14
|
+
// the settings form shows until the assistant can actually run, or
|
|
15
|
+
// whenever the user opens it explicitly
|
|
16
|
+
showSettings: ai.settingsOpen || !configured,
|
|
17
|
+
settings: {
|
|
18
|
+
provider: s.provider,
|
|
19
|
+
baseUrl: s.baseUrl,
|
|
20
|
+
model: s.model,
|
|
21
|
+
apiKey: s.apiKey,
|
|
22
|
+
needsKey: s.provider === 'openrouter' || s.provider === 'custom',
|
|
23
|
+
probe: {
|
|
24
|
+
status: ai.probe.status,
|
|
25
|
+
detail: ai.probe.detail,
|
|
26
|
+
busy: ai.probe.status === 'busy',
|
|
27
|
+
ok: ai.probe.status === 'ok',
|
|
28
|
+
fail: ai.probe.status === 'fail',
|
|
29
|
+
models: ai.probe.models.map((id) => ({ id })),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
providers: PROVIDER_OPTIONS.map((p) => ({ ...p, selected: p.value === s.provider })),
|
|
33
|
+
empty: ai.messages.length === 0,
|
|
34
|
+
messages: ai.messages.map((m) => (m.role === 'user'
|
|
35
|
+
? { role: 'user', text: m.content }
|
|
36
|
+
: { role: 'assistant', article: renderMarkdown(m.content) })),
|
|
37
|
+
// the reply currently streaming in (plain text: it changes per token)
|
|
38
|
+
streaming: ai.status === 'streaming',
|
|
39
|
+
pending: ai.pending,
|
|
40
|
+
// the quiet-phase label: reasoning models think before they speak,
|
|
41
|
+
// and watching the thinking grow beats a blind spinner
|
|
42
|
+
thinkingLabel: ai.reasoningChars > 0
|
|
43
|
+
? `Thinking… (${ai.reasoningChars} characters of reasoning)`
|
|
44
|
+
: 'Thinking…',
|
|
45
|
+
// the ledger: a persistent objective, what has been recorded against
|
|
46
|
+
// it, and what a compacted session can still reach. Every number here
|
|
47
|
+
// is derived from the ledger read, so the panel cannot claim a
|
|
48
|
+
// memory the store does not hold.
|
|
49
|
+
goal: ai.goal === null ? null : {
|
|
50
|
+
objective: ai.goal.objective,
|
|
51
|
+
entries: ai.goal.progress.length + (ai.goal.checkpoint?.sources.length ?? 0),
|
|
52
|
+
checkpointLabel: ai.goal.checkpoint ? `${ai.goal.checkpoint.sources.length} earlier progress entries retained with their evidence.` : '',
|
|
53
|
+
// newest first: the last thing that happened is the thing a reader
|
|
54
|
+
// wants, and the whole log would push the conversation off screen
|
|
55
|
+
progress: [...ai.goal.progress].reverse().slice(0, PROGRESS_SHOWN)
|
|
56
|
+
.map((entry) => ({ note: entry.note, evidence: entry.evidence })),
|
|
57
|
+
more: Math.max(0, ai.goal.progress.length - PROGRESS_SHOWN),
|
|
58
|
+
},
|
|
59
|
+
goalDraft: ai.goalDraft,
|
|
60
|
+
memories: ai.memories,
|
|
61
|
+
memoryLabel: `${ai.memories} remembered fact${ai.memories === 1 ? '' : 's'}`,
|
|
62
|
+
persistenceLabel: ai.persistence?.error ? `Storage failed: ${ai.persistence.error}`
|
|
63
|
+
: ai.persistence?.concurrency === 'single-writer' ? 'Use one tab at a time to update remembered state.' : '',
|
|
64
|
+
retentionLabel: ai.retention?.evicted?.length
|
|
65
|
+
? `${ai.retention.evicted.length} earlier archive address(es) evicted under the storage budget.` : '',
|
|
66
|
+
archived: ai.archived,
|
|
67
|
+
// said in full sentences, because "12" beside a chat is not
|
|
68
|
+
// information: a compacted session has to LOOK recoverable
|
|
69
|
+
archivedLabel: `${ai.archived} earlier round${ai.archived === 1 ? '' : 's'} archived —`
|
|
70
|
+
+ ' the assistant can fetch any of them back with recall.',
|
|
71
|
+
remembering: ai.remembering,
|
|
72
|
+
remembered: ai.remembered,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Progress entries the panel shows before it says "and N more". */
|
|
77
|
+
const PROGRESS_SHOWN = 3;
|