75 lines
8.1 KiB
JavaScript
75 lines
8.1 KiB
JavaScript
/**
|
|
* hal/ai/rooms.mjs — AI 채팅 방·메시지 저장과 통합 방의 호출 규칙(0.3.0).
|
|
*
|
|
* 방(ai_rooms)
|
|
* - 통합 방 `room_group`(kind=group): 항상 첫 탭, 이름 변경·삭제 불가. 연결된 모든 활성 모델이 함께 있다.
|
|
* - 개별 방(kind=direct): 연결(connection)마다 하나. 탭 이름(title)은 비우면 모델 이름이 기본값이고 운영자가 바꿀 수 있다.
|
|
* 메시지(ai_messages): 방 안에서 seq 가 단조 증가. role = user(운영자) · assistant(모델) · tool(도구 결과) · system(HAL 안내).
|
|
* content_json 은 블록 배열: text · tool_use · tool_result · proposal(승인 대기 제안 카드). relay_depth: 0 = 운영자 지시로 시작, 1 = 다른 모델의 @호출로 자동 1회 이어진 응답.
|
|
*
|
|
* 통합 방 호출 규칙(개발 지시 v0.3.0)
|
|
* - 운영자 지시는 활성 모델 모두에게 간다. `@이름` 또는 글머리 `이름:`/`이름,` 로 지정하면 그 모델만 반응한다. 모르는 이름을 @ 로 부르면 아무에게도 보내지 않고 오류를 낸다.
|
|
* - 모델이 `@이름` 으로 다른 모델을 부르면 그 모델이 자동으로 **1회** 답한다(relay_depth 1). 그 답이 또 누군가를 불러도 자동 반응은 없다 — 사용자 지시가 있어야 한다. 자기 자신·비활성 모델은 전달 대상이 아니다.
|
|
*/
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { redact } from '../../lib/redact.mjs';
|
|
|
|
export const GROUP_ROOM = 'room_group';
|
|
export function ensureGroupRoom(d) { if (!d.store.get('SELECT id FROM ai_rooms WHERE id=?', GROUP_ROOM)) d.store.run('INSERT INTO ai_rooms(id, kind, connection_id, title, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?)', GROUP_ROOM, 'group', null, '통합', null, nowIso(), nowIso()); }
|
|
export function ensureDirectRoom(d, conn) {
|
|
const r = d.store.get('SELECT * FROM ai_rooms WHERE kind=\'direct\' AND connection_id=?', conn.id); if (r) return r;
|
|
const id = newId('room_'); d.store.run('INSERT INTO ai_rooms(id, kind, connection_id, title, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?)', id, 'direct', conn.id, null, conn.createdBy || null, nowIso(), nowIso()); return d.store.get('SELECT * FROM ai_rooms WHERE id=?', id);
|
|
}
|
|
/** 연결 삭제 때: 그 연결의 개별 방·메시지·제안을 지운다(통합 방의 발언은 기록으로 남긴다). */
|
|
export function removeRoomsOf(d, connectionId) {
|
|
const rooms = d.store.all('SELECT id FROM ai_rooms WHERE kind=\'direct\' AND connection_id=?', connectionId);
|
|
d.store.tx(() => { for (const r of rooms) { d.store.run('DELETE FROM ai_messages WHERE room_id=?', r.id); d.store.run('DELETE FROM ai_actions WHERE room_id=?', r.id); d.store.run('DELETE FROM ai_rooms WHERE id=?', r.id); } d.store.run('UPDATE ai_actions SET status=\'cancelled\', decided_at=? WHERE connection_id=? AND status=\'proposed\'', nowIso(), connectionId); });
|
|
}
|
|
export function getRoom(d, id) { return d.store.get('SELECT * FROM ai_rooms WHERE id=?', id) || null; }
|
|
/** 탭 목록: 통합 방이 항상 먼저, 그다음 연결 순서(position)대로 개별 방. connections 는 connections.list(d) 의 공개 객체. */
|
|
export function listRooms(d, connections) {
|
|
ensureGroupRoom(d); const g = getRoom(d, GROUP_ROOM);
|
|
const out = [{ id: g.id, kind: 'group', title: '통합', fixed: true, connectionId: null, updatedAt: g.updated_at }];
|
|
for (const c of connections) { const r = ensureDirectRoom(d, c); out.push({ id: r.id, kind: 'direct', title: r.title || c.tabLabel || c.name, fixed: false, connectionId: c.id, connection: { id: c.id, name: c.name, status: c.status, provider: c.provider, model: c.model, models: c.models || [], authKind: c.authKind, toolsVia: c.toolsVia, lastError: c.lastError || null }, updatedAt: r.updated_at }); }
|
|
return out;
|
|
}
|
|
export function renameRoom(d, id, title) {
|
|
const r = getRoom(d, id); if (!r) throw new HttpError(404, 'room_not_found', '방이 없습니다'); if (r.kind === 'group') throw new HttpError(400, 'room_fixed', '통합 방은 이름을 바꿀 수 없습니다');
|
|
const t = String(title || '').trim(); if (!t || t.length > 60) throw new HttpError(400, 'bad_title', '탭 이름은 1~60자여야 합니다');
|
|
d.store.run('UPDATE ai_rooms SET title=?, updated_at=? WHERE id=?', t, nowIso(), id); return getRoom(d, id);
|
|
}
|
|
export function publicMessage(r) { return { id: r.id, roomId: r.room_id, seq: r.seq, role: r.role, connectionId: r.connection_id, author: r.author, content: parseJson(r.content_json, []), addressed: parseJson(r.addressed_json, null), relayDepth: r.relay_depth, status: r.status, usage: parseJson(r.usage_json, null), error: r.error, createdAt: r.created_at }; }
|
|
export function listMessages(d, roomId, { after = 0, limit = 500 } = {}) { return d.store.all('SELECT * FROM ai_messages WHERE room_id=? AND seq>? ORDER BY seq LIMIT ?', roomId, Number(after) || 0, Math.min(2000, limit)).map(publicMessage); }
|
|
export function appendMessage(d, { roomId, role, connectionId = null, author = null, content, addressed = null, relayDepth = 0, status = 'done', usage = null, error = null }) {
|
|
const id = newId('aim_');
|
|
d.store.tx(() => { const seq = (d.store.get('SELECT COALESCE(MAX(seq),0) AS s FROM ai_messages WHERE room_id=?', roomId).s || 0) + 1; d.store.run('INSERT INTO ai_messages(id, room_id, seq, role, connection_id, author, content_json, addressed_json, relay_depth, status, usage_json, error, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)', id, roomId, seq, role, connectionId, author, toJson(redact(content)), addressed ? toJson(addressed) : null, relayDepth, status, usage ? toJson(usage) : null, error, nowIso()); d.store.run('UPDATE ai_rooms SET updated_at=? WHERE id=?', nowIso(), roomId); });
|
|
return publicMessage(d.store.get('SELECT * FROM ai_messages WHERE id=?', id));
|
|
}
|
|
export function updateMessage(d, id, { content, status, usage, error } = {}) {
|
|
const r = d.store.get('SELECT * FROM ai_messages WHERE id=?', id); if (!r) return null;
|
|
d.store.run('UPDATE ai_messages SET content_json=?, status=?, usage_json=?, error=? WHERE id=?', content ? toJson(redact(content)) : r.content_json, status || r.status, usage ? toJson(usage) : r.usage_json, error === undefined ? r.error : error, id);
|
|
return publicMessage(d.store.get('SELECT * FROM ai_messages WHERE id=?', id));
|
|
}
|
|
/** 새 대화: 방의 기록을 비우고 승인 대기 제안은 취소한다. */
|
|
export function resetRoom(d, roomId) { d.store.tx(() => { d.store.run('DELETE FROM ai_messages WHERE room_id=?', roomId); d.store.run('UPDATE ai_actions SET status=\'cancelled\', decided_at=? WHERE room_id=? AND status=\'proposed\'', nowIso(), roomId); d.store.run('UPDATE ai_rooms SET updated_at=? WHERE id=?', nowIso(), roomId); }); }
|
|
|
|
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
const MENTION_RE = /@([^\s@,.:;!?()[\]"'<>]{1,40})/g;
|
|
function findParticipant(participants, name) { const n = norm(name); return participants.find((p) => norm(p.name) === n || (p.tabLabel && norm(p.tabLabel) === n)) || null; }
|
|
/**
|
|
* 운영자 지시의 수신자 해석. `@이름`(어디든) 과 글머리 `이름:`/`이름,` 을 본다.
|
|
* @returns {{ addressed: string[] (connection id, 중복 없음), unknown: string[] (모르는 @이름) }}
|
|
*/
|
|
export function parseAddressing(text, participants) {
|
|
const addressed = []; const unknown = []; const t = String(text || '');
|
|
for (const m of t.matchAll(MENTION_RE)) { const p = findParticipant(participants, m[1]); if (p) { if (!addressed.includes(p.id)) addressed.push(p.id); } else unknown.push(m[1]); }
|
|
const head = t.match(/^\s*([^\s:,@]{1,40})\s*[:,]/); if (head) { const p = findParticipant(participants, head[1]); if (p && !addressed.includes(p.id)) addressed.push(p.id); }
|
|
return { addressed, unknown };
|
|
}
|
|
/** 모델 응답 안의 `@이름` 전달 대상(자기 자신 제외, 활성 참여자만). */
|
|
export function mentionedParticipants(text, participants, selfId) {
|
|
const out = []; for (const m of String(text || '').matchAll(MENTION_RE)) { const p = findParticipant(participants, m[1]); if (p && p.id !== selfId && !out.some((x) => x.id === p.id)) out.push(p); } return out;
|
|
}
|