tycono 0.1.36 → 0.1.38
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/package.json +1 -1
- package/src/api/src/engine/agent-loop.ts +23 -1
- package/src/api/src/engine/llm-adapter.ts +2 -1
- package/src/api/src/engine/runners/claude-cli.ts +7 -1
- package/src/api/src/engine/runners/direct-api.ts +1 -0
- package/src/api/src/engine/runners/types.ts +10 -0
- package/src/api/src/routes/execute.ts +35 -6
- package/src/api/src/routes/speech.ts +104 -16
- package/src/api/src/services/job-manager.ts +6 -2
- package/src/api/src/services/session-store.ts +8 -0
- package/src/web/dist/assets/index-DthSRfI6.js +100 -0
- package/src/web/dist/assets/index-Dtjw8aGI.css +1 -0
- package/src/web/dist/assets/{preview-app-dwwoqVVX.js → preview-app-8XDl0hO6.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-BjsYCLHF.css +0 -1
- package/src/web/dist/assets/index-Bqa9AuK2.js +0 -100
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { validateDispatch } from './authority-validator.js';
|
|
|
5
5
|
import { getToolsForRole } from './tools/definitions.js';
|
|
6
6
|
import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
|
|
7
7
|
import { type TokenLedger } from '../services/token-ledger.js';
|
|
8
|
+
import { type ImageAttachment } from './runners/types.js';
|
|
8
9
|
|
|
9
10
|
/* ─── Types ──────────────────────────────────── */
|
|
10
11
|
|
|
@@ -24,6 +25,7 @@ export interface AgentConfig {
|
|
|
24
25
|
jobId?: string; // Job ID for token tracking
|
|
25
26
|
model?: string; // LLM model name for cost tracking
|
|
26
27
|
tokenLedger?: TokenLedger; // Token usage ledger (optional)
|
|
28
|
+
attachments?: ImageAttachment[]; // Image attachments for vision
|
|
27
29
|
// Callbacks
|
|
28
30
|
onText?: (text: string) => void;
|
|
29
31
|
onToolExec?: (name: string, input: Record<string, unknown>) => void;
|
|
@@ -133,8 +135,28 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
133
135
|
};
|
|
134
136
|
|
|
135
137
|
// 4. Run the loop
|
|
138
|
+
// Build initial user message with optional image attachments
|
|
139
|
+
const userContent: MessageContent[] = [];
|
|
140
|
+
|
|
141
|
+
// Add image attachments first (if any)
|
|
142
|
+
if (config.attachments && config.attachments.length > 0) {
|
|
143
|
+
for (const att of config.attachments) {
|
|
144
|
+
userContent.push({
|
|
145
|
+
type: 'image',
|
|
146
|
+
source: {
|
|
147
|
+
type: 'base64',
|
|
148
|
+
media_type: att.mediaType,
|
|
149
|
+
data: att.data,
|
|
150
|
+
},
|
|
151
|
+
} as unknown as MessageContent);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Add text content
|
|
156
|
+
userContent.push({ type: 'text', text: task });
|
|
157
|
+
|
|
136
158
|
const messages: LLMMessage[] = [
|
|
137
|
-
{ role: 'user', content: task },
|
|
159
|
+
{ role: 'user', content: userContent.length === 1 ? task : userContent },
|
|
138
160
|
];
|
|
139
161
|
|
|
140
162
|
let turns = 0;
|
|
@@ -23,7 +23,8 @@ export interface ToolResult {
|
|
|
23
23
|
|
|
24
24
|
export type MessageContent =
|
|
25
25
|
| { type: 'text'; text: string }
|
|
26
|
-
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
26
|
+
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
27
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
|
|
27
28
|
|
|
28
29
|
export interface LLMResponse {
|
|
29
30
|
content: MessageContent[];
|
|
@@ -128,7 +128,13 @@ else:
|
|
|
128
128
|
*/
|
|
129
129
|
export class ClaudeCliRunner implements ExecutionRunner {
|
|
130
130
|
execute(config: RunnerConfig, callbacks: RunnerCallbacks): RunnerHandle {
|
|
131
|
-
const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus } = config;
|
|
131
|
+
const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments } = config;
|
|
132
|
+
|
|
133
|
+
// Note: Claude CLI doesn't support inline image attachments.
|
|
134
|
+
// Images will be ignored with a warning if passed.
|
|
135
|
+
if (attachments && attachments.length > 0) {
|
|
136
|
+
console.warn(`[ClaudeCliRunner] Warning: Image attachments (${attachments.length}) are not supported in CLI mode. Use EXECUTION_ENGINE=direct-api for vision support.`);
|
|
137
|
+
}
|
|
132
138
|
|
|
133
139
|
// 1. Context Assembly
|
|
134
140
|
const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus });
|
|
@@ -40,6 +40,7 @@ export class DirectApiRunner implements ExecutionRunner {
|
|
|
40
40
|
jobId: config.jobId,
|
|
41
41
|
model: config.model,
|
|
42
42
|
tokenLedger,
|
|
43
|
+
attachments: config.attachments,
|
|
43
44
|
onText: (text) => callbacks.onText?.(text),
|
|
44
45
|
onToolExec: (name, input) => callbacks.onToolUse?.(name, input),
|
|
45
46
|
onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
|
|
@@ -12,6 +12,15 @@ import type { OrgTree } from '../org-tree.js';
|
|
|
12
12
|
* EXECUTION_ENGINE 환경변수로 전환 (기본값: claude-cli)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/* ─── Attachment Types ────────────────────────── */
|
|
16
|
+
|
|
17
|
+
export interface ImageAttachment {
|
|
18
|
+
type: 'image';
|
|
19
|
+
data: string; // base64 encoded
|
|
20
|
+
name: string;
|
|
21
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/* ─── Config ──────────────────────────────────── */
|
|
16
25
|
|
|
17
26
|
export type TeamStatus = Record<string, { status: string; task?: string }>;
|
|
@@ -27,6 +36,7 @@ export interface RunnerConfig {
|
|
|
27
36
|
model?: string;
|
|
28
37
|
jobId?: string;
|
|
29
38
|
teamStatus?: TeamStatus;
|
|
39
|
+
attachments?: ImageAttachment[];
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
/* ─── Callbacks ───────────────────────────────── */
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
addMessage,
|
|
9
9
|
updateMessage,
|
|
10
10
|
type Message,
|
|
11
|
+
type ImageAttachment,
|
|
11
12
|
} from '../services/session-store.js';
|
|
12
13
|
import { jobManager, type Job } from '../services/job-manager.js';
|
|
13
14
|
import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
|
|
@@ -28,6 +29,12 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
|
|
|
28
29
|
const url = req.url ?? '';
|
|
29
30
|
const method = req.method ?? '';
|
|
30
31
|
|
|
32
|
+
// ── /api/waves/save ──
|
|
33
|
+
if (method === 'POST' && url === '/api/waves/save') {
|
|
34
|
+
readBody(req).then((body) => handleSaveWave(body, res));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
// ── /api/jobs/* routes ──
|
|
32
39
|
if (url.startsWith('/api/jobs')) {
|
|
33
40
|
handleJobsRequest(url, method, req, res);
|
|
@@ -597,11 +604,32 @@ function handleSessionMessage(
|
|
|
597
604
|
|
|
598
605
|
const content = body.content as string;
|
|
599
606
|
const mode = (body.mode as 'talk' | 'do') ?? session.mode;
|
|
600
|
-
|
|
601
|
-
|
|
607
|
+
const attachments = body.attachments as ImageAttachment[] | undefined;
|
|
608
|
+
|
|
609
|
+
// Allow empty content if there are attachments
|
|
610
|
+
if (!content && (!attachments || attachments.length === 0)) {
|
|
611
|
+
jsonResponse(res, 400, { error: 'content or attachments required' });
|
|
602
612
|
return;
|
|
603
613
|
}
|
|
604
614
|
|
|
615
|
+
// Validate attachments if present
|
|
616
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
617
|
+
const SUPPORTED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
618
|
+
if (attachments && attachments.length > 0) {
|
|
619
|
+
for (const att of attachments) {
|
|
620
|
+
if (!SUPPORTED_TYPES.includes(att.mediaType)) {
|
|
621
|
+
jsonResponse(res, 400, { error: `Unsupported image type: ${att.mediaType}` });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// Approximate size check (base64 is ~33% larger than binary)
|
|
625
|
+
const approximateSize = (att.data.length * 3) / 4;
|
|
626
|
+
if (approximateSize > MAX_FILE_SIZE) {
|
|
627
|
+
jsonResponse(res, 400, { error: `File too large: ${att.name}. Max 5MB.` });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
605
633
|
const roleId = session.roleId;
|
|
606
634
|
const readOnly = mode === 'talk';
|
|
607
635
|
|
|
@@ -614,17 +642,18 @@ function handleSessionMessage(
|
|
|
614
642
|
const ceoMsg: Message = {
|
|
615
643
|
id: `msg-${Date.now()}-ceo`,
|
|
616
644
|
from: 'ceo',
|
|
617
|
-
content,
|
|
645
|
+
content: content || '',
|
|
618
646
|
type: mode === 'do' ? 'directive' : 'conversation',
|
|
619
647
|
status: 'done',
|
|
620
648
|
timestamp: new Date().toISOString(),
|
|
649
|
+
attachments,
|
|
621
650
|
};
|
|
622
651
|
addMessage(sessionId, ceoMsg);
|
|
623
652
|
|
|
624
653
|
const contextWindow = buildConversationContext(session.messages, ceoMsg);
|
|
625
654
|
const fullTask = contextWindow
|
|
626
|
-
? `${contextWindow}\n[Current Message]\nCEO: ${content}`
|
|
627
|
-
: content;
|
|
655
|
+
? `${contextWindow}\n[Current Message]\nCEO: ${content || '(image attached)'}`
|
|
656
|
+
: content || '(image attached)';
|
|
628
657
|
|
|
629
658
|
const roleMsg: Message = {
|
|
630
659
|
id: `msg-${Date.now() + 1}-role`,
|
|
@@ -700,7 +729,7 @@ function handleSessionMessage(
|
|
|
700
729
|
});
|
|
701
730
|
|
|
702
731
|
const handle = getRunner().execute(
|
|
703
|
-
{ companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model },
|
|
732
|
+
{ companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model, attachments },
|
|
704
733
|
{
|
|
705
734
|
onText: (text) => {
|
|
706
735
|
roleMsg.content += text;
|
|
@@ -263,6 +263,101 @@ function buildCompanyContext(): string {
|
|
|
263
263
|
: '';
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Build role-specific AKB context by pre-fetching relevant knowledge server-side.
|
|
268
|
+
* Works with ANY engine (including claude-cli which doesn't support tool_use).
|
|
269
|
+
*/
|
|
270
|
+
function buildRoleContext(roleId: string): string {
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
|
|
273
|
+
// 1. Role's own journal (most recent 2 entries)
|
|
274
|
+
try {
|
|
275
|
+
const journalDir = path.join(COMPANY_ROOT, 'roles', roleId, 'journal');
|
|
276
|
+
if (fs.existsSync(journalDir)) {
|
|
277
|
+
const files = fs.readdirSync(journalDir)
|
|
278
|
+
.filter(f => f.endsWith('.md'))
|
|
279
|
+
.sort()
|
|
280
|
+
.slice(-2);
|
|
281
|
+
for (const file of files) {
|
|
282
|
+
const content = fs.readFileSync(path.join(journalDir, file), 'utf-8');
|
|
283
|
+
// Extract title + first meaningful section (truncated)
|
|
284
|
+
const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
|
|
285
|
+
const body = content.split('\n').slice(1).join('\n').trim().slice(0, 400);
|
|
286
|
+
parts.push(`[Your Journal: ${file}] ${title}\n${body}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch { /* no journal */ }
|
|
290
|
+
|
|
291
|
+
// 2. Recent waves mentioning this role (last 3)
|
|
292
|
+
try {
|
|
293
|
+
const wavesDir = path.join(COMPANY_ROOT, 'operations', 'waves');
|
|
294
|
+
if (fs.existsSync(wavesDir)) {
|
|
295
|
+
const waveFiles = fs.readdirSync(wavesDir)
|
|
296
|
+
.filter(f => f.endsWith('.md'))
|
|
297
|
+
.sort()
|
|
298
|
+
.slice(-10); // scan last 10, pick up to 3 relevant
|
|
299
|
+
const relevant: string[] = [];
|
|
300
|
+
for (const file of waveFiles.reverse()) {
|
|
301
|
+
if (relevant.length >= 3) break;
|
|
302
|
+
const content = fs.readFileSync(path.join(wavesDir, file), 'utf-8');
|
|
303
|
+
const lower = content.toLowerCase();
|
|
304
|
+
if (lower.includes(roleId) || lower.includes('all roles') || lower.includes('전체')) {
|
|
305
|
+
const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
|
|
306
|
+
const tldr = content.match(/TL;DR[\s\S]*?\n\n/)?.[0]?.trim() ?? '';
|
|
307
|
+
const snippet = tldr || content.split('\n').slice(1, 6).join('\n').trim();
|
|
308
|
+
relevant.push(`[Wave: ${file}] ${title}\n${snippet.slice(0, 300)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (relevant.length > 0) parts.push(...relevant);
|
|
312
|
+
}
|
|
313
|
+
} catch { /* no waves */ }
|
|
314
|
+
|
|
315
|
+
// 3. Recent standup (latest, only this role's section)
|
|
316
|
+
try {
|
|
317
|
+
const standupDir = path.join(COMPANY_ROOT, 'operations', 'standup');
|
|
318
|
+
if (fs.existsSync(standupDir)) {
|
|
319
|
+
const standupFiles = fs.readdirSync(standupDir)
|
|
320
|
+
.filter(f => f.endsWith('.md'))
|
|
321
|
+
.sort()
|
|
322
|
+
.slice(-1);
|
|
323
|
+
for (const file of standupFiles) {
|
|
324
|
+
const content = fs.readFileSync(path.join(standupDir, file), 'utf-8');
|
|
325
|
+
// Try to extract this role's section from standup
|
|
326
|
+
const rolePattern = new RegExp(`(## .*${roleId}.*|### .*${roleId}.*)([\\s\\S]*?)(?=\\n## |\\n### |$)`, 'i');
|
|
327
|
+
const match = content.match(rolePattern);
|
|
328
|
+
if (match) {
|
|
329
|
+
parts.push(`[Standup: ${file}] Your report:\n${match[0].slice(0, 300)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch { /* no standups */ }
|
|
334
|
+
|
|
335
|
+
// 4. Recent decisions (last 2 approved — brief titles only, full list is in company context)
|
|
336
|
+
try {
|
|
337
|
+
const decisionsDir = path.join(COMPANY_ROOT, 'operations', 'decisions');
|
|
338
|
+
if (fs.existsSync(decisionsDir)) {
|
|
339
|
+
const files = fs.readdirSync(decisionsDir)
|
|
340
|
+
.filter(f => f.endsWith('.md') && f !== 'decisions.md')
|
|
341
|
+
.sort()
|
|
342
|
+
.slice(-3);
|
|
343
|
+
const decisions: string[] = [];
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
const content = fs.readFileSync(path.join(decisionsDir, file), 'utf-8');
|
|
346
|
+
const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
|
|
347
|
+
const summary = content.match(/## (?:Summary|TL;DR|요약)[\s\S]*?\n\n/)?.[0]?.trim() ?? '';
|
|
348
|
+
decisions.push(`- ${title}${summary ? ': ' + summary.split('\n').slice(1).join(' ').trim().slice(0, 150) : ''}`);
|
|
349
|
+
}
|
|
350
|
+
if (decisions.length > 0) {
|
|
351
|
+
parts.push(`[Recent Decisions]\n${decisions.join('\n')}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch { /* no decisions */ }
|
|
355
|
+
|
|
356
|
+
return parts.length > 0
|
|
357
|
+
? `\n\nYOUR KNOWLEDGE (real AKB context — reference this in conversation):\n${parts.join('\n\n')}`
|
|
358
|
+
: '';
|
|
359
|
+
}
|
|
360
|
+
|
|
266
361
|
/**
|
|
267
362
|
* Role-specific chat style guidelines.
|
|
268
363
|
* Makes each role sound distinctly different in conversations.
|
|
@@ -463,6 +558,9 @@ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunctio
|
|
|
463
558
|
// Build company context (cached per request — lightweight)
|
|
464
559
|
const companyCtx = buildCompanyContext();
|
|
465
560
|
|
|
561
|
+
// Build role-specific AKB context (pre-fetched, works with any engine)
|
|
562
|
+
const roleCtx = buildRoleContext(roleId);
|
|
563
|
+
|
|
466
564
|
// Role-specific communication style
|
|
467
565
|
const roleStyle = getRoleChatStyle(roleId, node.level);
|
|
468
566
|
|
|
@@ -471,6 +569,7 @@ Persona: ${persona}
|
|
|
471
569
|
${workCtx}
|
|
472
570
|
${levelCtx}
|
|
473
571
|
${companyCtx}
|
|
572
|
+
${roleCtx}
|
|
474
573
|
|
|
475
574
|
You are in the #${channelId} chat channel.${topicCtx}
|
|
476
575
|
Members: ${memberList}
|
|
@@ -478,22 +577,11 @@ ${relContext}
|
|
|
478
577
|
|
|
479
578
|
${roleStyle}
|
|
480
579
|
|
|
481
|
-
|
|
482
|
-
You have
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
- list_files: Discover what files exist in a directory
|
|
487
|
-
|
|
488
|
-
Useful paths to explore:
|
|
489
|
-
- operations/decisions/ — CEO decisions (what was decided and why)
|
|
490
|
-
- operations/waves/ — Work dispatches (what CEO asked teams to do)
|
|
491
|
-
- operations/standups/ — Daily standups (what everyone reported)
|
|
492
|
-
- projects/ — Active projects and their tasks
|
|
493
|
-
- roles/${roleId}/journal/ — Your own work journal
|
|
494
|
-
- knowledge/ — Domain knowledge
|
|
495
|
-
|
|
496
|
-
You don't need to search every time. But when the conversation touches on company work, decisions, or direction, DO search to find real facts rather than making up generic discussion.
|
|
580
|
+
GROUNDING (CRITICAL):
|
|
581
|
+
You have been given real company knowledge above under "YOUR KNOWLEDGE". This is from your journal, recent CEO waves, standups, and decisions.
|
|
582
|
+
You MUST reference this real context in your conversations — mention specific projects, decisions, tasks, or events by name.
|
|
583
|
+
Do NOT generate generic workplace chatter. Every message should show you're aware of what's actually happening in the company.
|
|
584
|
+
If your knowledge section mentions a specific decision or wave, reference it naturally (e.g. "after the test minimization decision..." or "CEO's wave about side panel...").
|
|
497
585
|
|
|
498
586
|
CONVERSATION RULES:
|
|
499
587
|
1. Stay deeply in character — your expertise, vocabulary, and concerns should be DISTINCT from other roles.
|
|
@@ -46,6 +46,8 @@ export interface StartJobParams {
|
|
|
46
46
|
readOnly?: boolean;
|
|
47
47
|
parentJobId?: string;
|
|
48
48
|
model?: string;
|
|
49
|
+
/** If true, this is a continuation from CEO reply — skip question detection */
|
|
50
|
+
isContinuation?: boolean;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/* ─── Helpers ────────────────────────────── */
|
|
@@ -218,7 +220,8 @@ class JobManager {
|
|
|
218
220
|
};
|
|
219
221
|
|
|
220
222
|
// Check if output ends with a question → awaiting_input
|
|
221
|
-
|
|
223
|
+
// Skip for continuation jobs (CEO already replied once — avoid infinite loop)
|
|
224
|
+
if (!params.isContinuation && hasQuestion(result.output)) {
|
|
222
225
|
job.status = 'awaiting_input';
|
|
223
226
|
stream.emit('job:awaiting_input', params.roleId, {
|
|
224
227
|
...doneData,
|
|
@@ -344,13 +347,14 @@ class JobManager {
|
|
|
344
347
|
: prevOutput;
|
|
345
348
|
const continuationTask = `[Continuation — previous output follows]\n${contextSummary}\n\n[CEO Response]\n${response}`;
|
|
346
349
|
|
|
347
|
-
// Create new job for same role
|
|
350
|
+
// Create new job for same role (mark as continuation to skip question detection)
|
|
348
351
|
const newJob = this.startJob({
|
|
349
352
|
type: job.type,
|
|
350
353
|
roleId: job.roleId,
|
|
351
354
|
task: continuationTask,
|
|
352
355
|
sourceRole: 'ceo',
|
|
353
356
|
parentJobId: job.id,
|
|
357
|
+
isContinuation: true,
|
|
354
358
|
});
|
|
355
359
|
|
|
356
360
|
job.childJobIds.push(newJob.id);
|
|
@@ -4,6 +4,13 @@ import { COMPANY_ROOT } from './file-reader.js';
|
|
|
4
4
|
|
|
5
5
|
/* ─── Types ─────────────────────────────── */
|
|
6
6
|
|
|
7
|
+
export interface ImageAttachment {
|
|
8
|
+
type: 'image';
|
|
9
|
+
data: string; // base64 encoded
|
|
10
|
+
name: string;
|
|
11
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export interface Message {
|
|
8
15
|
id: string;
|
|
9
16
|
from: 'ceo' | 'role';
|
|
@@ -11,6 +18,7 @@ export interface Message {
|
|
|
11
18
|
type: 'conversation' | 'directive' | 'system';
|
|
12
19
|
status?: 'streaming' | 'done' | 'error';
|
|
13
20
|
timestamp: string;
|
|
21
|
+
attachments?: ImageAttachment[];
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export interface Session {
|