Compare commits
6 Commits
c17763f59d
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 017093077f | |||
| af6dc560ea | |||
| 065de83ffd | |||
| a8dd23675a | |||
| cb5872c326 | |||
| af54e875df |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.21",
|
"version": "1.2.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.21",
|
"version": "1.2.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.21",
|
"version": "1.2.25",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import struct
|
import struct
|
||||||
@@ -207,6 +208,8 @@ class SlamNode(Node):
|
|||||||
self._fallback_point_cloud: Optional[PointCloud2] = None
|
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||||
self._color_point_cloud: Optional[PointCloud2] = None
|
self._color_point_cloud: Optional[PointCloud2] = None
|
||||||
self._camera_image: Optional[CompressedImage] = None
|
self._camera_image: Optional[CompressedImage] = None
|
||||||
|
self._camera_frames = {}
|
||||||
|
self._camera_subscriptions = {}
|
||||||
self._icp_odom: Optional[Odometry] = None
|
self._icp_odom: Optional[Odometry] = None
|
||||||
self._map: Optional[OccupancyGrid] = None
|
self._map: Optional[OccupancyGrid] = None
|
||||||
self._plan: Optional[NavPath] = None
|
self._plan: Optional[NavPath] = None
|
||||||
@@ -261,6 +264,8 @@ class SlamNode(Node):
|
|||||||
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
||||||
if not os.environ.get("CAMERA_IMAGE_TOPIC"):
|
if not os.environ.get("CAMERA_IMAGE_TOPIC"):
|
||||||
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
|
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
|
||||||
|
self._camera_discovery_timer = self.create_timer(
|
||||||
|
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
||||||
@@ -625,11 +630,45 @@ class SlamNode(Node):
|
|||||||
self._color_point_cloud_count += 1
|
self._color_point_cloud_count += 1
|
||||||
self._last_color_point_cloud_wall_time = time.time()
|
self._last_color_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
def _on_camera_image(self, msg: CompressedImage):
|
def _discover_cameras(self):
|
||||||
|
try:
|
||||||
|
topics = sorted(name for name, types in self.get_topic_names_and_types()
|
||||||
|
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
|
||||||
|
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for topic in topics:
|
||||||
|
camera_id = topic.split('/')[-2]
|
||||||
|
if camera_id not in self._camera_subscriptions:
|
||||||
|
self._camera_subscriptions[camera_id] = self.create_subscription(
|
||||||
|
CompressedImage, topic,
|
||||||
|
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
|
||||||
|
qos_profile_sensor_data)
|
||||||
|
active = {topic.split('/')[-2] for topic in topics}
|
||||||
|
for camera_id in list(self._camera_subscriptions):
|
||||||
|
if camera_id not in active:
|
||||||
|
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._camera_frames.pop(camera_id, None)
|
||||||
|
|
||||||
|
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
|
||||||
|
with self._lock:
|
||||||
|
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
|
||||||
|
return
|
||||||
self._camera_image = msg
|
self._camera_image = msg
|
||||||
self._camera_image_count += 1
|
self._camera_image_count += 1
|
||||||
self._last_camera_image_wall_time = time.time()
|
self._last_camera_image_wall_time = time.time()
|
||||||
|
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
|
||||||
|
topic or self.camera_image_topic)
|
||||||
|
|
||||||
|
def camera_sources(self):
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
frames = list(self._camera_frames.items())
|
||||||
|
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
|
||||||
|
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
|
||||||
|
and bool(image.data) and 'jpeg' in image.format.lower()}
|
||||||
|
for key, (image, received, topic) in sorted(frames)]
|
||||||
|
|
||||||
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -687,7 +726,6 @@ class SlamNode(Node):
|
|||||||
point_cloud_last = self._last_point_cloud_wall_time
|
point_cloud_last = self._last_point_cloud_wall_time
|
||||||
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
||||||
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
||||||
camera_image_last = self._last_camera_image_wall_time
|
|
||||||
map_count = self._map_count
|
map_count = self._map_count
|
||||||
plan_count = self._plan_count
|
plan_count = self._plan_count
|
||||||
|
|
||||||
@@ -727,7 +765,7 @@ class SlamNode(Node):
|
|||||||
},
|
},
|
||||||
"camera_image": {
|
"camera_image": {
|
||||||
"name": self.camera_image_topic,
|
"name": self.camera_image_topic,
|
||||||
"available": received_recently(camera_image_last),
|
"available": any(camera['available'] for camera in self.camera_sources()),
|
||||||
},
|
},
|
||||||
"plan": {"name": self.plan_topic, "available": plan_available},
|
"plan": {"name": self.plan_topic, "available": plan_available},
|
||||||
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
||||||
@@ -1238,6 +1276,7 @@ class SlamNode(Node):
|
|||||||
"sensor_point_cloud": sensor_point_cloud_payload,
|
"sensor_point_cloud": sensor_point_cloud_payload,
|
||||||
"color_point_cloud": color_point_cloud_payload,
|
"color_point_cloud": color_point_cloud_payload,
|
||||||
"camera_image": camera_image_payload,
|
"camera_image": camera_image_payload,
|
||||||
|
"cameras": self.camera_sources(),
|
||||||
"map": map_payload,
|
"map": map_payload,
|
||||||
"tf": {
|
"tf": {
|
||||||
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
||||||
@@ -1302,8 +1341,14 @@ class SlamNode(Node):
|
|||||||
payload["received_at"] = received_at
|
payload["received_at"] = received_at
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def camera_frame_jpeg(self) -> Optional[bytes]:
|
def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if camera_id is not None:
|
||||||
|
entry = self._camera_frames.get(camera_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
image, received_at, _topic = entry
|
||||||
|
else:
|
||||||
image = self._camera_image
|
image = self._camera_image
|
||||||
received_at = self._last_camera_image_wall_time
|
received_at = self._last_camera_image_wall_time
|
||||||
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
||||||
@@ -1531,10 +1576,10 @@ def color_point_cloud():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/camera-frame")
|
@app.get("/camera-frame")
|
||||||
def camera_frame():
|
def camera_frame(camera_id: Optional[str] = None):
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
frame = slam_node.camera_frame_jpeg()
|
frame = slam_node.camera_frame_jpeg(camera_id)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
||||||
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Optional
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class CameraTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||||
|
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||||
|
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
|
||||||
|
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
|
||||||
|
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
|
||||||
|
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
|
||||||
|
'qos_profile_sensor_data': object()}
|
||||||
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
|
||||||
|
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
|
||||||
|
self.node._lock = threading.Lock()
|
||||||
|
self.node._camera_frames = {}
|
||||||
|
self.node._camera_subscriptions = {}
|
||||||
|
self.node._camera_image = None
|
||||||
|
self.node._last_camera_image_wall_time = None
|
||||||
|
self.node._camera_image_count = 0
|
||||||
|
self.node.camera_image_topic = '/camera_test/image_jpeg'
|
||||||
|
self.topics = []
|
||||||
|
self.callbacks = {}
|
||||||
|
self.node.get_topic_names_and_types = lambda: self.topics
|
||||||
|
def subscribe(kind, topic, callback, qos):
|
||||||
|
self.callbacks[topic] = callback
|
||||||
|
return topic
|
||||||
|
self.node.create_subscription = subscribe
|
||||||
|
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
|
||||||
|
|
||||||
|
def test_independent_frames_and_disconnect(self):
|
||||||
|
for key in ['video0', 'video2']:
|
||||||
|
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
|
||||||
|
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
|
||||||
|
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertEqual(len(self.callbacks), 2)
|
||||||
|
for topic, callback in self.callbacks.items():
|
||||||
|
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
|
||||||
|
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
|
||||||
|
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
|
||||||
|
self.now += 4
|
||||||
|
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
|
||||||
|
self.topics = self.topics[:1]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertNotIn('video2', self.node._camera_frames)
|
||||||
|
|
||||||
|
def test_single_camera_compatibility_and_non_jpeg(self):
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
|
||||||
|
self.assertFalse(self.node.camera_sources()[0]['available'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -98,6 +98,51 @@ test('legacy cancelled references can be opened with the same read-only navigati
|
|||||||
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('code navigation does not block or abort a pending model response', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
const controller = new AbortController()
|
||||||
|
h.globals.abortControllerRef.current = controller
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
|
||||||
|
assert.equal(isCurrent(), true)
|
||||||
|
opened++
|
||||||
|
})
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(controller.signal.aborted, false)
|
||||||
|
assert.equal(h.globals.abortControllerRef.current, controller)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an arriving model response does not cancel an in-flight code navigation', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.globals.abortControllerRef.current = new AbortController()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise(resolve => { finish = resolve })
|
||||||
|
})
|
||||||
|
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
|
||||||
|
h.globals.abortControllerRef.current = null
|
||||||
|
assert.equal(current(), true)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starting file application invalidates a navigation that is still reading', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
|
||||||
|
})
|
||||||
|
h.globals.applyingRef.current = true
|
||||||
|
assert.equal(current(), false)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
||||||
const h = consentHarness()
|
const h = consentHarness()
|
||||||
let finish, opened = 0
|
let finish, opened = 0
|
||||||
@@ -143,7 +188,8 @@ test('a new question cancels pending navigation without leaving its old state st
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('restored interrupted navigation becomes retryable without reopening any file', () => {
|
test('restored interrupted navigation becomes retryable without reopening any file', () => {
|
||||||
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
|
const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
|
||||||
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
|
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
|
||||||
const restored = recoverInterruptedAgentMessages([message])[0]
|
const restored = recoverInterruptedAgentMessages([message])[0]
|
||||||
assert.equal(restored.codeReferenceStates.pending.status, 'error')
|
assert.equal(restored.codeReferenceStates.pending.status, 'error')
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ const vm = require('node:vm')
|
|||||||
const ts = require('typescript')
|
const ts = require('typescript')
|
||||||
|
|
||||||
const output = {}
|
const output = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
}).outputText, { exports: output, DOMException })
|
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
const { resolveAgentContext } = output
|
const { resolveAgentContext } = output
|
||||||
const plain = value => JSON.parse(JSON.stringify(value))
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const api = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: api })
|
||||||
|
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
|
||||||
|
const now = Date.now()
|
||||||
|
const local = scope('local', 'local-host', '/workspace/a')
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const turn = (n, workspace = local, createdAt = now) => [
|
||||||
|
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
|
||||||
|
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
|
||||||
|
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
|
||||||
|
} }
|
||||||
|
]
|
||||||
|
|
||||||
|
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
|
||||||
|
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
|
||||||
|
messages.push({ ...turn(20)[0], content: 'pending current question' })
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
|
||||||
|
assert.equal(context.summary, '누적 기억-19')
|
||||||
|
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hard caps payload text even with huge original answers and requests', () => {
|
||||||
|
const messages = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const pair = turn(i)
|
||||||
|
pair[0].content = 'q'.repeat(4000)
|
||||||
|
pair[1].plan.summary = 's'.repeat(500)
|
||||||
|
pair[1].plan.explanation = 'e'.repeat(12000)
|
||||||
|
pair[1].plan.conversationSummary = 'm'.repeat(2000)
|
||||||
|
return pair
|
||||||
|
}).flat()
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.ok(context.summary.length <= 1000)
|
||||||
|
assert.equal(context.recentMessages.length, 4)
|
||||||
|
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
|
||||||
|
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
|
||||||
|
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
|
||||||
|
assert.equal(build(turn(1, other), local, now), undefined)
|
||||||
|
}
|
||||||
|
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
|
||||||
|
assert.equal(scope('local', 'local-host'), undefined)
|
||||||
|
assert.equal(build(turn(1), undefined, now), undefined)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
|
||||||
|
const first = turn(1), second = turn(2)
|
||||||
|
delete second[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
second[1].plan.conversationSummary = ''
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
delete first[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '')
|
||||||
|
second[1].plan.conversationSummary = '새 누적 요약'
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
|
||||||
|
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
|
||||||
|
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
|
||||||
|
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
|
||||||
|
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
|
||||||
|
pair[1].applyState = 'cancelled'
|
||||||
|
const context = JSON.stringify(build(pair, local, now))
|
||||||
|
assert.match(context, /Control\/node.py:20-23/)
|
||||||
|
assert.match(context, /file change: cancelled/)
|
||||||
|
assert.match(context, /build: completed/)
|
||||||
|
assert.doesNotMatch(context, /PRIVATE_/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores errors, interrupted turns and initial lookup responses', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
const error = { ...pair[1], plan: undefined, content: 'network error' }
|
||||||
|
assert.equal(build([pair[0], error], local, now), undefined)
|
||||||
|
pair[1].plan.toolRequests = [{ name: 'files.read' }]
|
||||||
|
assert.equal(build(pair, local, now), undefined)
|
||||||
|
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
|
||||||
|
pair[1].commandExecutions = { build: { status: 'failed' } }
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
|
||||||
|
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
|
||||||
|
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, filename)
|
||||||
|
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
|
||||||
|
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||||
|
const file = (startLine, endLine, extra = {}, result = {}) => ({
|
||||||
|
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
|
||||||
|
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
|
||||||
|
})
|
||||||
|
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
|
||||||
|
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
|
||||||
|
|
||||||
|
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
|
||||||
|
assert.equal(results.length, 1)
|
||||||
|
const data = readFileEvidence(results[0])
|
||||||
|
assert.equal(data.startLine, 1)
|
||||||
|
assert.equal(data.endLine, 20)
|
||||||
|
assert.equal(data.content.split('\n').length, 20)
|
||||||
|
assert.equal(data.complete, false)
|
||||||
|
assert.equal(results[0].truncated, true)
|
||||||
|
assert.equal(missingAgentRead(read(3, 16), results), undefined)
|
||||||
|
assert.deepEqual(missingAgentRead(read(), results), read())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
|
||||||
|
assert.equal(results.length, 2)
|
||||||
|
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
|
||||||
|
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
|
||||||
|
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
|
||||||
|
assert.equal(changed.length, 1)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
|
||||||
|
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
|
||||||
|
assert.equal(hosts.length, 2)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('full and missing observations are reused but partial character fragments are never coverage', () => {
|
||||||
|
const full = file(1, 10, { complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
|
||||||
|
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
|
||||||
|
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(), [missing]), undefined)
|
||||||
|
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
|
||||||
|
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
|
||||||
|
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
|
||||||
|
assert.equal(large.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
|
||||||
|
const executions = []
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (final) {
|
||||||
|
assert.equal(context.toolResults.length, 1)
|
||||||
|
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
|
||||||
|
assert.equal(calls, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
|
||||||
|
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
|
||||||
|
requestPlan: async context => {
|
||||||
|
assert.equal(context.toolResults.length, 12)
|
||||||
|
assert.equal(context.toolResults[0].name, 'files.read')
|
||||||
|
assert.match(context.contextNotes[0], /4개/)
|
||||||
|
assert.ok(context.contextNotes.length <= 12)
|
||||||
|
assert.ok(context.contextNotes.every(note => note.length <= 500))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('cached file must not be queried again') },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (!final) return answer([read(1, 5, 'file0.yaml')])
|
||||||
|
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('already cached') },
|
||||||
|
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
|
||||||
|
})
|
||||||
|
assert.equal(result.plan.codeReferences.length, 1)
|
||||||
|
assert.match(result.plan.explanation, /verified code/)
|
||||||
|
assert.deepEqual(result.plan.changes, [])
|
||||||
|
assert.deepEqual(result.plan.commands, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovery never resurrects code locations after a different file revision was observed', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
|
||||||
|
requestPlan: async (_, final) => {
|
||||||
|
calls++
|
||||||
|
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.plan.codeReferences, [])
|
||||||
|
assert.match(result.plan.summary, /확정하지 못했습니다/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
|
||||||
|
function compile(path, overrides = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve(path), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||||
|
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
|
||||||
|
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
|
||||||
|
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
|
||||||
|
assert.deepEqual(plain(status.models), options)
|
||||||
|
assert.equal(status.selectionSupported, true)
|
||||||
|
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selects the saved provider only while it is available, and never invents one', () => {
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
function selectorHarness(props) {
|
||||||
|
const state = [], refs = []
|
||||||
|
let stateIndex = 0, refIndex = 0
|
||||||
|
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
|
||||||
|
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
|
||||||
|
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
|
||||||
|
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
|
||||||
|
let selected
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
|
||||||
|
const tree = h.render()
|
||||||
|
const trigger = elements(tree, node => node.type === 'button')[0]
|
||||||
|
assert.equal(trigger.props['aria-haspopup'], 'menu')
|
||||||
|
assert.equal(trigger.props['aria-expanded'], false)
|
||||||
|
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
|
||||||
|
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.equal(elements(tree, node => node.type === 'select').length, 0)
|
||||||
|
trigger.props.onClick()
|
||||||
|
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
|
||||||
|
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||||
|
assert.match(menu.props.className, /fixed/)
|
||||||
|
const items = elements(menu, node => node.props.role === 'menuitemradio')
|
||||||
|
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
|
||||||
|
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
|
||||||
|
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
|
||||||
|
items[0].props.onClick()
|
||||||
|
assert.equal(selected, 'OPENAI')
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
|
||||||
|
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
|
||||||
|
assert.ok(empty.props.disabled)
|
||||||
|
assert.equal(empty.props.title, '사용 가능한 모델 없음')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
|
||||||
|
const trigger = elements(h.render(), node => node.type === 'button')[0]
|
||||||
|
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
|
||||||
|
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('input footer places the model action before the attachment and clear controls', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A-Copilot has a single managed label', () => {
|
||||||
|
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
|
||||||
|
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
|
||||||
|
})
|
||||||
@@ -11,7 +11,11 @@ function compile(source, globals = {}) {
|
|||||||
}).outputText, { exports, DOMException, Error, ...globals })
|
}).outputText, { exports, DOMException, Error, ...globals })
|
||||||
return exports
|
return exports
|
||||||
}
|
}
|
||||||
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
|
const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
|
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
|
||||||
|
})
|
||||||
|
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
|
||||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
|
||||||
@@ -26,22 +30,110 @@ function harness(requestApi) {
|
|||||||
let now = 1000
|
let now = 1000
|
||||||
class Clock extends Date { static now() { return now } }
|
class Clock extends Date { static now() { return now } }
|
||||||
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
||||||
const messageStateRef = { current: { session } }
|
const messageStateRef = { current: { session, messages: [] } }
|
||||||
|
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
|
||||||
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
||||||
...context, session, messageStateRef, AbortController,
|
...context, ...conversation, session, messageStateRef, AbortController, FormData,
|
||||||
Date: Clock,
|
Date: Clock,
|
||||||
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false,
|
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
|
||||||
status: { enabled: true }, abortControllerRef: { current: null },
|
selectedModel,
|
||||||
|
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
|
||||||
setPending: value => { state.pending = value },
|
setPending: value => { state.pending = value },
|
||||||
setRequestStartedAt: value => { state.requestStartedAt = value },
|
setRequestStartedAt: value => { state.requestStartedAt = value },
|
||||||
setActivity: value => { state.activity = value },
|
setActivity: value => { state.activity = value },
|
||||||
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
||||||
updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) },
|
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
|
||||||
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
||||||
})
|
})
|
||||||
return { ...api, state, messageStateRef, advance: ms => { now += ms } }
|
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
|
||||||
|
const snapshot = deferred(), completion = deferred()
|
||||||
|
const bodies = []
|
||||||
|
let loads = 0
|
||||||
|
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
|
||||||
|
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
|
||||||
|
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
|
||||||
|
})
|
||||||
|
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
|
||||||
|
assert.equal(h.state.saved[0][0].role, 'user')
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(bodies.length, 0)
|
||||||
|
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
h.advance(5000)
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
|
||||||
|
await tick()
|
||||||
|
assert.equal(bodies.length, 1)
|
||||||
|
assert.equal(bodies[0].provider, 'QWEN')
|
||||||
|
assert.equal(bodies[0].modules[0].state, 'running')
|
||||||
|
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
h.advance(2000)
|
||||||
|
completion.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 7000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0, signal
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
|
||||||
|
h.stop()
|
||||||
|
assert.equal(signal.aborted, true)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
|
||||||
|
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
|
||||||
|
else old.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await first
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
next.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await second
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
assert.equal(h.state.messages.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.advance(3000)
|
||||||
|
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages[0].content, '질문')
|
||||||
|
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 3000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
snapshot.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
||||||
const first = deferred(), tool = deferred(), final = deferred()
|
const first = deferred(), tool = deferred(), final = deferred()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
@@ -76,6 +168,82 @@ test('publishes completed stages immediately without persisting progress or addi
|
|||||||
assert.equal(h.state.notices, 1)
|
assert.equal(h.state.notices, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
|
||||||
|
const bodies = []
|
||||||
|
let uploads = 0
|
||||||
|
const h = harness(async (path, request) => {
|
||||||
|
if (path.endsWith('/attachments/images')) {
|
||||||
|
uploads++
|
||||||
|
assert.equal(request.body.get('provider'), 'QWEN')
|
||||||
|
assert.equal(request.body.get('courseId'), 'course')
|
||||||
|
assert.equal(request.body.get('week'), '3')
|
||||||
|
assert.equal(request.body.get('file').name, 'screen.png')
|
||||||
|
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
|
||||||
|
}
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
return bodies.length === 1
|
||||||
|
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
|
||||||
|
: response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
|
||||||
|
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
|
||||||
|
})
|
||||||
|
assert.equal(uploads, 1)
|
||||||
|
assert.equal(bodies.length, 2)
|
||||||
|
assert.ok(bodies.every(body => body.provider === 'QWEN'))
|
||||||
|
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
|
||||||
|
assert.equal(h.state.messages.at(-1).model, 'fixture')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote target and observed source ranges reach each provider request including finalization', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
return bodies.length < 3 ? response([tool]) : response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
|
||||||
|
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
|
||||||
|
})
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
for (const body of bodies) {
|
||||||
|
assert.equal(body.environment, 'remote')
|
||||||
|
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
|
||||||
|
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
}
|
||||||
|
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
|
||||||
|
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
|
||||||
|
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
|
||||||
|
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
|
||||||
|
})
|
||||||
|
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
|
||||||
|
await h.requestPlan('Control 설정 위치', [], [], options)
|
||||||
|
assert.equal(bodies[0].conversationContext, undefined)
|
||||||
|
await h.requestPlan('그 파일 설명해줘', [], [], options)
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
|
||||||
|
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
|
||||||
|
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
|
||||||
|
assert.equal(bodies[1].workspaceRoot, undefined)
|
||||||
|
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
test('errors replace temporary progress with the existing error reply', async () => {
|
test('errors replace temporary progress with the existing error reply', async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const h = harness(async () => {
|
const h = harness(async () => {
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ const fs = require('node:fs')
|
|||||||
const vm = require('node:vm')
|
const vm = require('node:vm')
|
||||||
const ts = require('typescript')
|
const ts = require('typescript')
|
||||||
const contextApi = {}
|
const contextApi = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
}).outputText, { exports: contextApi, DOMException })
|
}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
|
|
||||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
let payloadExpression
|
let payloadExpression
|
||||||
@@ -28,15 +32,16 @@ test('user messages preserve only typed text while attachments remain separate m
|
|||||||
find(source)
|
find(source)
|
||||||
assert.ok(initializer)
|
assert.ok(initializer)
|
||||||
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||||
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } })
|
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||||
assert.equal(message.content, request)
|
assert.equal(message.content, request)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) {
|
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
||||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||||
textAttachments: attachments ?? [], imageTokens,
|
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
|
||||||
|
selection: provider ? { provider } : {},
|
||||||
agentPlanRequestText: contextApi.agentPlanRequestText,
|
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||||
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||||
}))
|
}))
|
||||||
@@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f
|
|||||||
assert.match(body.request, /설정 위치 알려줘/)
|
assert.match(body.request, /설정 위치 알려줘/)
|
||||||
assert.match(body.request, /최종 답변/)
|
assert.match(body.request, /최종 답변/)
|
||||||
assert.equal(Object.hasOwn(body, 'finalize'), false)
|
assert.equal(Object.hasOwn(body, 'finalize'), false)
|
||||||
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week'])
|
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('finalization keeps the request within the backend 4000-character limit', () => {
|
test('finalization keeps the request within the backend 4000-character limit', () => {
|
||||||
@@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', (
|
|||||||
assert.equal(body.request.length, 4000)
|
assert.equal(body.request.length, 4000)
|
||||||
assert.match(body.request, /원래 학생 질문/)
|
assert.match(body.request, /원래 학생 질문/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('sends only a provider selection rather than student-supplied models or keys', () => {
|
||||||
|
for (const finalize of [false, true]) {
|
||||||
|
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
|
||||||
|
assert.equal(body.provider, 'OPENAI')
|
||||||
|
assert.equal(Object.hasOwn(body, 'model'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'apiKey'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
|
||||||
|
}
|
||||||
|
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
|
||||||
|
})
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ function renderResponse(message, now, reducedMotion = false, extra = {}) {
|
|||||||
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('internal cumulative memory is not rendered in the response bubble', () => {
|
||||||
|
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
|
||||||
|
assert.match(html, /화면 설명/)
|
||||||
|
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
|
||||||
|
})
|
||||||
|
|
||||||
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
||||||
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
||||||
assert.ok(html.includes('A-Copilot'))
|
assert.ok(html.includes('A-Copilot'))
|
||||||
|
|||||||
@@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) {
|
|||||||
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
|
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
|
||||||
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
|
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
|
||||||
assert.equal(full.data.complete, true)
|
assert.equal(full.data.complete, true)
|
||||||
|
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
|
||||||
|
assert.equal(rangedFull.data.complete, true)
|
||||||
|
assert.equal(rangedFull.truncated, false)
|
||||||
|
assert.equal(rangedFull.data.endLine, 3)
|
||||||
|
assert.equal(rangedFull.data.contentTruncated, false)
|
||||||
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
|
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
|
||||||
assert.equal(partial.data.content, '조향: ackermann')
|
assert.equal(partial.data.content, '조향: ackermann')
|
||||||
assert.equal(partial.data.complete, false)
|
assert.equal(partial.data.complete, false)
|
||||||
assert.equal(partial.truncated, true)
|
assert.equal(partial.truncated, true)
|
||||||
|
assert.equal(partial.data.endLine, 2)
|
||||||
|
assert.equal(partial.data.contentTruncated, false)
|
||||||
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
|
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
|
||||||
assert.equal(missing.data.exists, false)
|
assert.equal(missing.data.exists, false)
|
||||||
assert.equal(missing.data.hash, null)
|
assert.equal(missing.data.hash, null)
|
||||||
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
|
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
|
||||||
})
|
})
|
||||||
|
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
|
||||||
|
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
|
||||||
|
assert.equal(readout.data.complete, false)
|
||||||
|
assert.equal(readout.data.contentTruncated, true)
|
||||||
|
assert.equal(readout.data.endLine, 1)
|
||||||
|
})
|
||||||
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
|
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
|
||||||
const root = await fixture(t)
|
const root = await fixture(t)
|
||||||
await fs.mkdir(path.join(root, 'code'))
|
await fs.mkdir(path.join(root, 'code'))
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const declarations = new Map()
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(app)
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
function harness(remoteWorkspace) {
|
||||||
|
const executions = []
|
||||||
|
const calls = []
|
||||||
|
const contexts = []
|
||||||
|
const events = []
|
||||||
|
const errors = []
|
||||||
|
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
|
||||||
|
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
|
||||||
|
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
|
||||||
|
const exports = {}
|
||||||
|
const globals = {
|
||||||
|
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
|
||||||
|
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
|
||||||
|
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
|
||||||
|
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
|
||||||
|
window: {
|
||||||
|
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
|
||||||
|
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
|
||||||
|
},
|
||||||
|
requestAgentPlan: async (...args) => {
|
||||||
|
calls.push(args)
|
||||||
|
events.push('plan-start')
|
||||||
|
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
|
||||||
|
},
|
||||||
|
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
|
||||||
|
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||||
|
}
|
||||||
|
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
|
||||||
|
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, globals)
|
||||||
|
return { ...exports, executions, calls, contexts, events, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remote of [false, true]) {
|
||||||
|
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
await h.submitAgentRequest('최대 속도 제한 코드 위치')
|
||||||
|
assert.deepEqual(h.errors, [])
|
||||||
|
assert.equal(h.calls.length, 1)
|
||||||
|
const [, initialModules, , options] = h.calls[0]
|
||||||
|
assert.deepEqual(plain(initialModules), [])
|
||||||
|
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
|
||||||
|
const { modules } = h.contexts[0]
|
||||||
|
assert.equal(modules[0].state, 'running')
|
||||||
|
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
|
||||||
|
assert.deepEqual(plain(modules[0].files), [])
|
||||||
|
assert.equal(options.environment, remote ? 'remote' : 'local')
|
||||||
|
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
|
||||||
|
await h.executeWorkspaceAgentTool(tool)
|
||||||
|
assert.equal(h.executions.length, 1)
|
||||||
|
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
|
||||||
|
const args = h.executions[0].args
|
||||||
|
assert.deepEqual(args[remote ? 1 : 0], tool)
|
||||||
|
assert.equal(args[2], 'vehicle')
|
||||||
|
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
|
|||||||
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||||
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
||||||
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||||
|
const AgentCodeReferences = () => null
|
||||||
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||||
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
||||||
})
|
})
|
||||||
@@ -36,6 +37,7 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
|
|||||||
ChatEmptyState: widgets.ChatEmptyState,
|
ChatEmptyState: widgets.ChatEmptyState,
|
||||||
ProgressiveChatResponse,
|
ProgressiveChatResponse,
|
||||||
ChatImageAttachments,
|
ChatImageAttachments,
|
||||||
|
AgentCodeReferences,
|
||||||
AgentProgressBubble,
|
AgentProgressBubble,
|
||||||
AgentWorkDuration,
|
AgentWorkDuration,
|
||||||
MessageCopyButton: () => null,
|
MessageCopyButton: () => null,
|
||||||
@@ -43,6 +45,27 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
|
|||||||
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('existing code locations stay usable during model and command work but not file application', () => {
|
||||||
|
for (const applying of [false, true]) {
|
||||||
|
const onOpen = () => {}
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
||||||
|
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
||||||
|
onOpenCodeReference: onOpen
|
||||||
|
})
|
||||||
|
let references
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
if (element.type === AgentCodeReferences) references = element
|
||||||
|
React.Children.toArray(element.props.children).forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(references)
|
||||||
|
assert.equal(references.props.busy, applying)
|
||||||
|
assert.equal(typeof references.props.onOpen, 'function')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||||
const tree = messages.AgentMessages({
|
const tree = messages.AgentMessages({
|
||||||
dark: true, enabled: true, statusLoading: false, pending: false,
|
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function setup(overrides = {}, browser = {}) {
|
|||||||
'../../components/AgentProgressBubble': {},
|
'../../components/AgentProgressBubble': {},
|
||||||
'../../components/AgentWorkDuration': {},
|
'../../components/AgentWorkDuration': {},
|
||||||
'../../components/AgentCodeReferences': {},
|
'../../components/AgentCodeReferences': {},
|
||||||
|
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
|
||||||
'react-dom': { createPortal: child => child },
|
'react-dom': { createPortal: child => child },
|
||||||
react: {
|
react: {
|
||||||
...React,
|
...React,
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest):
|
|||||||
try {
|
try {
|
||||||
const content = await readText(root, path)
|
const content = await readText(root, path)
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
|
||||||
const startLine = args.startLine ?? 1
|
const startLine = args.startLine ?? 1
|
||||||
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content
|
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
||||||
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial }
|
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
|
||||||
|
const selected = selection.slice(0, 40_000)
|
||||||
|
const contentTruncated = selected.length < selection.length
|
||||||
|
const complete = startLine === 1 && selected === content
|
||||||
|
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
|
||||||
|
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
// A missing leaf in an existing directory is evidence for a new file.
|
// A missing leaf in an existing directory is evidence for a new file.
|
||||||
|
|||||||
@@ -116,10 +116,15 @@ else:
|
|||||||
try:
|
try:
|
||||||
content = read(root, path)
|
content = read(root, path)
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
||||||
start = options.get('startLine', 1)
|
start = options.get('startLine', 1)
|
||||||
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content
|
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
|
||||||
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)}
|
selected = selection[:40000]
|
||||||
|
content_truncated = len(selected) < len(selection)
|
||||||
|
complete = start == 1 and selected == content
|
||||||
|
partial = not complete
|
||||||
|
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
|
||||||
|
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
checked(root, path, True)
|
checked(root, path, True)
|
||||||
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
||||||
|
|||||||
+29
-2
@@ -2563,6 +2563,23 @@ async function startRemoteVisualizationBridge(
|
|||||||
].join('\n'), 900_000)
|
].join('\n'), 900_000)
|
||||||
}
|
}
|
||||||
const command = bridgeContainerCommand(stack)
|
const command = bridgeContainerCommand(stack)
|
||||||
|
let bridgeContextRevision: string | null = null
|
||||||
|
if (stack !== 'autoware') {
|
||||||
|
const context = resolveResourceContextPath('resources/slam-ros-bridge')
|
||||||
|
if (!context) throw new Error('Bundled SLAM bridge source is missing')
|
||||||
|
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
|
||||||
|
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
|
||||||
|
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
|
||||||
|
'import json, os, pathlib, sys',
|
||||||
|
'data = json.load(sys.stdin)',
|
||||||
|
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
|
||||||
|
'root.mkdir(parents=True, exist_ok=True)',
|
||||||
|
'for name in ("bridge.py", "requirements.txt"):',
|
||||||
|
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
|
||||||
|
' temporary.write_text(data["files"][name], encoding="utf-8")',
|
||||||
|
' temporary.replace(root / name)',
|
||||||
|
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
|
||||||
|
}
|
||||||
|
|
||||||
reportVisualizationProgress('시각화 시작 중...')
|
reportVisualizationProgress('시각화 시작 중...')
|
||||||
|
|
||||||
@@ -2580,6 +2597,7 @@ async function startRemoteVisualizationBridge(
|
|||||||
'docker run -d --name "$CONTAINER" --net=host',
|
'docker run -d --name "$CONTAINER" --net=host',
|
||||||
'-v /dev/shm:/dev/shm',
|
'-v /dev/shm:/dev/shm',
|
||||||
'-v "$ROOT:/workspace"',
|
'-v "$ROOT:/workspace"',
|
||||||
|
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
|
||||||
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
|
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
|
||||||
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
|
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
|
||||||
'-e ROS_LOCALHOST_ONLY=0',
|
'-e ROS_LOCALHOST_ONLY=0',
|
||||||
@@ -4777,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
|
|||||||
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
|
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
|
||||||
...yahboomDevicePreflight(),
|
...yahboomDevicePreflight(),
|
||||||
'CAMERA_DEVICE=""',
|
'CAMERA_DEVICE=""',
|
||||||
|
'CAMERA_DEVICES=()',
|
||||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
|
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
|
||||||
' CAMERA_DEVICE=/dev/video0',
|
' CAMERA_DEVICE=/dev/video0',
|
||||||
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
|
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
|
||||||
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }',
|
' if [ "$CAMERA_DEVICE" = /dev ]; then',
|
||||||
|
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
|
||||||
|
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
|
||||||
|
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
|
||||||
|
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
|
||||||
|
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
|
||||||
|
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
|
||||||
|
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
|
||||||
|
' done',
|
||||||
'fi',
|
'fi',
|
||||||
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
|
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
|
||||||
'',
|
'',
|
||||||
@@ -4800,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
|
|||||||
')',
|
')',
|
||||||
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
|
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
|
||||||
'if [ -n "$CAMERA_DEVICE" ]; then',
|
'if [ -n "$CAMERA_DEVICE" ]; then',
|
||||||
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")',
|
' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
|
||||||
' for capture_device in /dev/capture-vi-channel*; do',
|
' for capture_device in /dev/capture-vi-channel*; do',
|
||||||
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
|
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
|
||||||
' done',
|
' done',
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
|
||||||
|
const cameras = new Map();
|
||||||
|
let enabled = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function sources() {
|
||||||
|
const { state, receivedAt } = getState();
|
||||||
|
if (!enabled || Date.now() - receivedAt >= 3000) return [];
|
||||||
|
const list = Array.isArray(state?.cameras) ? state.cameras
|
||||||
|
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
|
||||||
|
const seen = new Set();
|
||||||
|
return list.filter(item => {
|
||||||
|
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
}).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFrame(camera) {
|
||||||
|
if (camera.url) URL.revokeObjectURL(camera.url);
|
||||||
|
camera.url = null;
|
||||||
|
camera.image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, camera) {
|
||||||
|
cameras.delete(id);
|
||||||
|
camera.controller?.abort();
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frame(id, camera) {
|
||||||
|
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
camera.controller = controller;
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||||
|
try {
|
||||||
|
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
|
||||||
|
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
|
||||||
|
if (!response.ok) throw new Error('Camera frame unavailable');
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
|
||||||
|
const previous = camera.url;
|
||||||
|
camera.url = URL.createObjectURL(blob);
|
||||||
|
camera.image.src = camera.url;
|
||||||
|
camera.retryAt = 0;
|
||||||
|
if (previous) URL.revokeObjectURL(previous);
|
||||||
|
} catch (_) {
|
||||||
|
if (cameras.get(id) === camera) {
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
clearFrame(camera);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
if (camera.controller === controller) camera.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const live = sources();
|
||||||
|
const ids = new Set(live.map(item => item.id));
|
||||||
|
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
|
||||||
|
for (const item of live) {
|
||||||
|
let camera = cameras.get(item.id);
|
||||||
|
if (!camera) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'camera-preview-item';
|
||||||
|
element.dataset.cameraId = item.id;
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'camera-preview-label';
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'camera-preview-image';
|
||||||
|
element.append(label, image);
|
||||||
|
root.append(element);
|
||||||
|
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
|
||||||
|
image.addEventListener('error', () => {
|
||||||
|
if (cameras.get(item.id) === camera) {
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cameras.set(item.id, camera);
|
||||||
|
}
|
||||||
|
camera.label.textContent = item.name || item.id;
|
||||||
|
camera.image.alt = item.name || item.id;
|
||||||
|
void frame(item.id, camera);
|
||||||
|
}
|
||||||
|
onAvailable(enabled && cameras.size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityChanged() {
|
||||||
|
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
|
||||||
|
else refresh();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityChanged);
|
||||||
|
|
||||||
|
function setEnabled(value) {
|
||||||
|
enabled = value;
|
||||||
|
if (timer) window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
refresh();
|
||||||
|
if (enabled) timer = window.setInterval(refresh, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
dispose() {
|
||||||
|
setEnabled(false);
|
||||||
|
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -61,14 +61,29 @@
|
|||||||
#cameraPreviewWrap.is-visible { display:block; }
|
#cameraPreviewWrap.is-visible { display:block; }
|
||||||
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
|
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
|
||||||
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
||||||
#cameraPreview {
|
#cameraPreviewList {
|
||||||
|
max-height:min(60vh, 600px);
|
||||||
|
overflow-y:auto;
|
||||||
|
scrollbar-width:thin;
|
||||||
|
scrollbar-color:rgba(148,163,184,.65) transparent;
|
||||||
|
}
|
||||||
|
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
|
||||||
|
.camera-preview-label {
|
||||||
|
padding:5px 8px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
color:rgba(255,255,255,.85);
|
||||||
|
font:11px sans-serif;
|
||||||
|
}
|
||||||
|
.camera-preview-image {
|
||||||
display:block;
|
display:block;
|
||||||
width:100%;
|
width:100%;
|
||||||
aspect-ratio:16 / 9;
|
aspect-ratio:16 / 9;
|
||||||
object-fit:contain;
|
object-fit:contain;
|
||||||
background:#111520;
|
background:#111520;
|
||||||
}
|
}
|
||||||
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
|
#cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
|
||||||
#cameraPreviewToggle {
|
#cameraPreviewToggle {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
left:0;
|
left:0;
|
||||||
@@ -426,7 +441,7 @@
|
|||||||
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
|
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
|
||||||
<div id="cameraPreviewWrap">
|
<div id="cameraPreviewWrap">
|
||||||
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
|
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
|
||||||
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
|
<div id="cameraPreviewList"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="keyGuide">
|
<div id="keyGuide">
|
||||||
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
|
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
|
||||||
@@ -463,6 +478,7 @@
|
|||||||
<script type="module" src="./locale.js"></script>
|
<script type="module" src="./locale.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { t, setText } from './locale.js';
|
import { t, setText } from './locale.js';
|
||||||
|
import { createCameraPreview } from './camera-preview.js';
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
@@ -502,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
|
|||||||
const miniCtx = miniMap.getContext('2d');
|
const miniCtx = miniMap.getContext('2d');
|
||||||
const miniMapToggle = document.getElementById('miniMapToggle');
|
const miniMapToggle = document.getElementById('miniMapToggle');
|
||||||
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
||||||
const cameraPreview = document.getElementById('cameraPreview');
|
const cameraPreviewList = document.getElementById('cameraPreviewList');
|
||||||
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
||||||
const cameraToggleButton = document.getElementById('cameraToggle');
|
const cameraToggleButton = document.getElementById('cameraToggle');
|
||||||
const topRightControls = document.getElementById('topRightControls');
|
const topRightControls = document.getElementById('topRightControls');
|
||||||
@@ -937,12 +953,7 @@ let pointCloudRefreshTimer = null;
|
|||||||
let savedMapPointCloudLoaded = false;
|
let savedMapPointCloudLoaded = false;
|
||||||
let sensorPointCloudRequestInFlight = false;
|
let sensorPointCloudRequestInFlight = false;
|
||||||
let sensorPointCloudRefreshTimer = null;
|
let sensorPointCloudRefreshTimer = null;
|
||||||
let cameraFrameRequestInFlight = false;
|
|
||||||
let cameraFrameRetryAt = 0;
|
|
||||||
let cameraFrameRefreshTimer = null;
|
|
||||||
let cameraFrameObjectUrl = null;
|
|
||||||
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
||||||
let cameraFrameAbortController = null;
|
|
||||||
let pathVisible = false;
|
let pathVisible = false;
|
||||||
// SLAM always renders its live scan, including the forward beam. Navigation
|
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||||
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||||
@@ -2087,84 +2098,27 @@ async function refreshSensorPointCloud() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCameraPreviewAvailable(available) {
|
const cameraPreviewController = createCameraPreview({
|
||||||
available = available && cameraPreviewEnabled;
|
root: cameraPreviewList,
|
||||||
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
baseUrl: BACKEND_URL,
|
||||||
if (available) return;
|
getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
|
||||||
if (cameraFrameObjectUrl) {
|
onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
|
||||||
URL.revokeObjectURL(cameraFrameObjectUrl);
|
});
|
||||||
cameraFrameObjectUrl = null;
|
|
||||||
}
|
|
||||||
if (cameraPreview) cameraPreview.removeAttribute('src');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasLiveCameraState() {
|
|
||||||
return latestSlamState?.topic_status?.camera_image?.available === true
|
|
||||||
&& Date.now() - latestSlamStateReceivedAt < 3000;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCameraFrame() {
|
|
||||||
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
|
|
||||||
// Reuse websocket topic status instead of probing an absent camera with HTTP.
|
|
||||||
if (!hasLiveCameraState()) {
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() < cameraFrameRetryAt) return;
|
|
||||||
cameraFrameRequestInFlight = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
cameraFrameAbortController = controller;
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
cameraFrameRetryAt = Date.now() + 5000;
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frame = await response.blob();
|
|
||||||
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cameraFrameRetryAt = 0;
|
|
||||||
const nextUrl = URL.createObjectURL(frame);
|
|
||||||
const previousUrl = cameraFrameObjectUrl;
|
|
||||||
cameraFrameObjectUrl = nextUrl;
|
|
||||||
if (cameraPreview) cameraPreview.src = nextUrl;
|
|
||||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
|
||||||
} catch (_) {
|
|
||||||
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
cameraFrameAbortController = null;
|
|
||||||
cameraFrameRequestInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCameraPreviewEnabled(enabled) {
|
function setCameraPreviewEnabled(enabled) {
|
||||||
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
|
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
|
||||||
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
|
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
|
||||||
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
|
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
|
||||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
cameraPreviewController.setEnabled(cameraPreviewEnabled);
|
||||||
cameraFrameRefreshTimer = null;
|
|
||||||
if (!cameraPreviewEnabled) {
|
if (!cameraPreviewEnabled) {
|
||||||
cameraFrameAbortController?.abort();
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cameraFrameRetryAt = 0;
|
|
||||||
cameraPreviewWrap.classList.remove('is-collapsed');
|
cameraPreviewWrap.classList.remove('is-collapsed');
|
||||||
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
|
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
|
||||||
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
|
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
|
||||||
void refreshCameraFrame();
|
|
||||||
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
|
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
|
||||||
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
|
|
||||||
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
|
|
||||||
|
|
||||||
function drawMiniMap(state) {
|
function drawMiniMap(state) {
|
||||||
pendingMiniMapState = state;
|
pendingMiniMapState = state;
|
||||||
@@ -3299,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
|
|||||||
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
||||||
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
||||||
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
||||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
cameraPreviewController.dispose();
|
||||||
cameraFrameAbortController?.abort();
|
|
||||||
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function connectControlSocket() {
|
function connectControlSocket() {
|
||||||
|
|||||||
@@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
enabled: agentEnabled,
|
enabled: agentEnabled,
|
||||||
provider: agentProvider,
|
provider: agentProvider,
|
||||||
imagesEnabled: agentImagesEnabled,
|
imagesEnabled: agentImagesEnabled,
|
||||||
|
models: agentModels,
|
||||||
|
selectProvider: selectAgentProvider,
|
||||||
statusLoading: agentStatusLoading,
|
statusLoading: agentStatusLoading,
|
||||||
pending: agentPending,
|
pending: agentPending,
|
||||||
activity: agentActivity,
|
activity: agentActivity,
|
||||||
@@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||||
await createCustomModuleRecord(input)
|
await createCustomModuleRecord(input)
|
||||||
}
|
}
|
||||||
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
|
const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
|
||||||
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
|
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
|
||||||
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
|
const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
|
||||||
name: module.name,
|
name: module.name,
|
||||||
folderName: module.folderName,
|
folderName: module.folderName,
|
||||||
category: module.category,
|
category: module.category,
|
||||||
@@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
} : {})
|
} : {})
|
||||||
}))
|
}))
|
||||||
const targetModules = availableModules.filter((module) => (
|
const targetModules = moduleSnapshot.filter((module) => (
|
||||||
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
|
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
|
||||||
))
|
))
|
||||||
if (!targetModules.length) return workspaceModules
|
if (!targetModules.length) return workspaceModules
|
||||||
@@ -3677,12 +3679,18 @@ function LocalPcWorkspaceMockup({
|
|||||||
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
||||||
if (agentActionControllerRef.current || agentApplying) return
|
if (agentActionControllerRef.current || agentApplying) return
|
||||||
try {
|
try {
|
||||||
const modules = await collectAgentWorkspace()
|
await requestAgentPlan(content, [], [], {
|
||||||
await requestAgentPlan(content, modules, [], {
|
|
||||||
attachments,
|
attachments,
|
||||||
|
loadInitialContext: async (signal) => {
|
||||||
|
const modules = await readAgentModules()
|
||||||
|
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||||
|
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
|
||||||
|
},
|
||||||
loadRequestedContext: loadAgentRequestedContext,
|
loadRequestedContext: loadAgentRequestedContext,
|
||||||
executeTool: executeWorkspaceAgentTool,
|
executeTool: executeWorkspaceAgentTool,
|
||||||
environment: remoteWorkspace ? 'remote' : 'local'
|
environment: remoteWorkspace ? 'remote' : 'local',
|
||||||
|
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[agent] workspace collection failed', error)
|
console.error('[agent] workspace collection failed', error)
|
||||||
@@ -3690,7 +3698,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
|
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
|
||||||
if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
|
if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
|
||||||
validateCodeReference(reference)
|
validateCodeReference(reference)
|
||||||
const scope = agentNavigationScope
|
const scope = agentNavigationScope
|
||||||
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
|
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
|
||||||
@@ -3824,7 +3832,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
const currentModules = await readAgentModules()
|
const currentModules = await readAgentModules()
|
||||||
assertActive()
|
assertActive()
|
||||||
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
|
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
|
||||||
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
|
||||||
|
executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
||||||
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
|
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3906,7 +3916,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
showUserMessage: false,
|
showUserMessage: false,
|
||||||
loadRequestedContext: loadAgentRequestedContext,
|
loadRequestedContext: loadAgentRequestedContext,
|
||||||
executeTool: executeWorkspaceAgentTool,
|
executeTool: executeWorkspaceAgentTool,
|
||||||
environment: remoteWorkspace ? 'remote' : 'local'
|
environment: remoteWorkspace ? 'remote' : 'local',
|
||||||
|
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
||||||
@@ -4752,6 +4764,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
agentEnabled={agentEnabled}
|
agentEnabled={agentEnabled}
|
||||||
agentProvider={agentProvider}
|
agentProvider={agentProvider}
|
||||||
agentImagesEnabled={Boolean(agentImagesEnabled)}
|
agentImagesEnabled={Boolean(agentImagesEnabled)}
|
||||||
|
agentModels={agentModels}
|
||||||
|
onAgentProviderChange={selectAgentProvider}
|
||||||
agentStatusLoading={agentStatusLoading}
|
agentStatusLoading={agentStatusLoading}
|
||||||
agentPending={agentPending}
|
agentPending={agentPending}
|
||||||
agentActivity={agentActivity}
|
agentActivity={agentActivity}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Bot, Check } from 'lucide-react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
|
||||||
|
|
||||||
|
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
|
||||||
|
models: AgentModelOption[]
|
||||||
|
provider: string | null
|
||||||
|
disabled: boolean
|
||||||
|
dark: boolean
|
||||||
|
onChange: (provider: string) => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const selected = models.find(model => model.provider === provider)
|
||||||
|
const blocked = disabled || models.length === 0
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuId = useId()
|
||||||
|
const visible = open && !blocked
|
||||||
|
const close = (restoreFocus = false): void => {
|
||||||
|
setOpen(false)
|
||||||
|
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocked) setOpen(false)
|
||||||
|
}, [blocked])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible || !buttonRef.current) return
|
||||||
|
const bounds = buttonRef.current.getBoundingClientRect()
|
||||||
|
const width = Math.min(320, window.innerWidth - 16)
|
||||||
|
setPosition({
|
||||||
|
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
|
||||||
|
bottom: window.innerHeight - bounds.top + 6,
|
||||||
|
width,
|
||||||
|
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
|
||||||
|
})
|
||||||
|
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
||||||
|
const index = Math.max(0, models.findIndex(model => model.provider === provider))
|
||||||
|
items?.[index]?.focus({ preventScroll: true })
|
||||||
|
}, [visible, models, provider])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const outside = (event: Event): void => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
|
||||||
|
}
|
||||||
|
const dismiss = (): void => setOpen(false)
|
||||||
|
window.addEventListener('pointerdown', outside, true)
|
||||||
|
window.addEventListener('contextmenu', outside)
|
||||||
|
window.addEventListener('scroll', outside, true)
|
||||||
|
window.addEventListener('resize', dismiss)
|
||||||
|
window.addEventListener('blur', dismiss)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', outside, true)
|
||||||
|
window.removeEventListener('contextmenu', outside)
|
||||||
|
window.removeEventListener('scroll', outside, true)
|
||||||
|
window.removeEventListener('resize', dismiss)
|
||||||
|
window.removeEventListener('blur', dismiss)
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('에이전트 모델 선택')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={visible}
|
||||||
|
aria-controls={visible ? menuId : undefined}
|
||||||
|
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => setOpen(current => !current)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
|
||||||
|
else if (event.key === 'Escape') close()
|
||||||
|
}}
|
||||||
|
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
|
||||||
|
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
|
||||||
|
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
|
||||||
|
>
|
||||||
|
<Bot size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{visible && createPortal(
|
||||||
|
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
|
||||||
|
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||||
|
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
|
||||||
|
style={position}
|
||||||
|
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
|
||||||
|
if (event.key === 'Tab') { close(true); return }
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||||
|
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||||
|
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||||
|
items[next]?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
|
||||||
|
title={agentModelLabel(model)} tabIndex={-1}
|
||||||
|
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
|
||||||
|
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
|
||||||
|
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
|
||||||
|
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
|
||||||
|
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>, document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
imagesEnabled = false,
|
imagesEnabled = false,
|
||||||
onClearConversation,
|
onClearConversation,
|
||||||
clearDisabled = false,
|
clearDisabled = false,
|
||||||
|
footerAction,
|
||||||
onStop,
|
onStop,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: {
|
}: {
|
||||||
@@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
imagesEnabled?: boolean
|
imagesEnabled?: boolean
|
||||||
onClearConversation?: () => void
|
onClearConversation?: () => void
|
||||||
clearDisabled?: boolean
|
clearDisabled?: boolean
|
||||||
|
footerAction?: React.ReactNode
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
@@ -456,8 +458,9 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
)}
|
)}
|
||||||
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
|
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
|
||||||
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
|
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
|
||||||
{(attachmentsEnabled || onClearConversation) && (
|
{(attachmentsEnabled || onClearConversation || footerAction) && (
|
||||||
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
||||||
|
{footerAction}
|
||||||
{attachmentsEnabled && (
|
{attachmentsEnabled && (
|
||||||
<>
|
<>
|
||||||
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
|
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments'
|
|||||||
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
|
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
|
||||||
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
|
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
|
||||||
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
|
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
|
||||||
|
import { AgentModelSelector } from '../../components/AgentModelSelector'
|
||||||
|
import type { AgentModelOption } from './agentModels'
|
||||||
import type { AgentProgressStep } from './agentContext'
|
import type { AgentProgressStep } from './agentContext'
|
||||||
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||||
@@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = {
|
|||||||
agentEnabled: boolean
|
agentEnabled: boolean
|
||||||
agentProvider?: string | null
|
agentProvider?: string | null
|
||||||
agentImagesEnabled?: boolean
|
agentImagesEnabled?: boolean
|
||||||
|
agentModels?: AgentModelOption[]
|
||||||
|
onAgentProviderChange?: (provider: string) => void
|
||||||
agentStatusLoading: boolean
|
agentStatusLoading: boolean
|
||||||
agentPending: boolean
|
agentPending: boolean
|
||||||
agentActivity?: string
|
agentActivity?: string
|
||||||
@@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({
|
|||||||
agentEnabled,
|
agentEnabled,
|
||||||
agentProvider,
|
agentProvider,
|
||||||
agentImagesEnabled = false,
|
agentImagesEnabled = false,
|
||||||
|
agentModels = [],
|
||||||
|
onAgentProviderChange,
|
||||||
agentStatusLoading,
|
agentStatusLoading,
|
||||||
agentPending,
|
agentPending,
|
||||||
agentActivity,
|
agentActivity,
|
||||||
@@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({
|
|||||||
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
|
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
|
||||||
onClearConversation={requestClearConversation}
|
onClearConversation={requestClearConversation}
|
||||||
clearDisabled={clearDisabled}
|
clearDisabled={clearDisabled}
|
||||||
|
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
|
||||||
/>
|
/>
|
||||||
{clearConfirmationOpen && createPortal(
|
{clearConfirmationOpen && createPortal(
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
@@ -429,6 +436,8 @@ function AgentMessages({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{messages.map((message, index) => message.role === 'user' ? (
|
{messages.map((message, index) => message.role === 'user' ? (
|
||||||
@@ -447,7 +456,7 @@ function AgentMessages({
|
|||||||
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
||||||
{message.plan && (
|
{message.plan && (
|
||||||
<>
|
<>
|
||||||
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
||||||
{!!message.plan.evidence?.length && (
|
{!!message.plan.evidence?.length && (
|
||||||
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
||||||
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
|
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
|
||||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||||
|
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
|
||||||
|
|
||||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
|
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
|
||||||
export type AgentContextRequest = { modules: string[]; logs: string[] }
|
export type AgentContextRequest = { modules: string[]; logs: string[] }
|
||||||
export type AgentProgressStep = {
|
export type AgentProgressStep = {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,11 +50,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
|
|||||||
|
|
||||||
export function agentPlanRequestText(request: string, finalize = false): string {
|
export function agentPlanRequestText(request: string, finalize = false): string {
|
||||||
if (!finalize) return request
|
if (!finalize) return request
|
||||||
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
||||||
return instruction + request.slice(0, 4000 - instruction.length)
|
return instruction + request.slice(0, 4000 - instruction.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
|
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
|
||||||
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
|
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
|
||||||
const selected = new Map<string, AgentWorkspaceModule['files']>()
|
const selected = new Map<string, AgentWorkspaceModule['files']>()
|
||||||
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
|
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
|
||||||
@@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
|
|||||||
return { ...log, content, ...(notes ? { message: notes } : {}) }
|
return { ...log, content, ...(notes ? { message: notes } : {}) }
|
||||||
})
|
})
|
||||||
let toolSize = 0
|
let toolSize = 0
|
||||||
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => {
|
const evidence = compactAgentEvidence(context.toolResults ?? [])
|
||||||
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false
|
const priority = (result: AgentToolResult): number => {
|
||||||
|
const file = readFileEvidence(result)
|
||||||
|
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
|
||||||
|
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
|
||||||
|
: file || result.status === 'error' ? 3 : 1
|
||||||
|
}
|
||||||
|
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
|
||||||
|
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
|
||||||
|
const retained = new Set<AgentToolResult>()
|
||||||
|
for (const { result } of candidatesByPriority) {
|
||||||
|
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
|
||||||
|
retained.add(result)
|
||||||
toolSize += result.data.length
|
toolSize += result.data.length
|
||||||
return true
|
}
|
||||||
}).slice(0, 12).reverse()
|
const toolResults = evidence.filter(result => retained.has(result))
|
||||||
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
|
const omittedTools = evidence.filter(result => !retained.has(result))
|
||||||
|
const contextNotes = omittedTools.length
|
||||||
|
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
|
||||||
|
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
|
||||||
|
: []
|
||||||
|
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAgentContext({
|
export async function resolveAgentContext({
|
||||||
@@ -142,6 +159,7 @@ export async function resolveAgentContext({
|
|||||||
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
|
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
|
||||||
const queriedTools = new Set<string>()
|
const queriedTools = new Set<string>()
|
||||||
let toolCount = 0
|
let toolCount = 0
|
||||||
|
let confirmedAnswer: AgentPlanResponse | undefined
|
||||||
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
|
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
|
||||||
const usage: NonNullable<AgentConversationMessage['usage']> = {}
|
const usage: NonNullable<AgentConversationMessage['usage']> = {}
|
||||||
const checkAborted = (): void => {
|
const checkAborted = (): void => {
|
||||||
@@ -162,9 +180,17 @@ export async function resolveAgentContext({
|
|||||||
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
|
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
|
||||||
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
|
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
|
||||||
)).join('\n').slice(0, 8000)
|
)).join('\n').slice(0, 8000)
|
||||||
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000)
|
const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
|
||||||
|
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
|
||||||
|
const references = candidate?.plan.codeReferences?.filter(reference => {
|
||||||
|
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
|
||||||
|
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
|
||||||
|
return !latest || latest.exists && latest.hash === reference.expectedHash
|
||||||
|
})
|
||||||
|
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
|
||||||
|
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
|
||||||
const explanation = stillRequesting
|
const explanation = stillRequesting
|
||||||
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
|
? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
|
||||||
: response.plan.explanation
|
: response.plan.explanation
|
||||||
return {
|
return {
|
||||||
...response,
|
...response,
|
||||||
@@ -172,7 +198,8 @@ export async function resolveAgentContext({
|
|||||||
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
|
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
|
||||||
plan: {
|
plan: {
|
||||||
...response.plan,
|
...response.plan,
|
||||||
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
||||||
|
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
|
||||||
explanation,
|
explanation,
|
||||||
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
|
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
|
||||||
}
|
}
|
||||||
@@ -186,19 +213,31 @@ export async function resolveAgentContext({
|
|||||||
const response = await requestPlan(context)
|
const response = await requestPlan(context)
|
||||||
checkAborted()
|
checkAborted()
|
||||||
addUsage(response)
|
addUsage(response)
|
||||||
|
if (response.plan.codeReferences?.length) confirmedAnswer = response
|
||||||
const modules = [...new Set(response.plan.requestedModules ?? [])]
|
const modules = [...new Set(response.plan.requestedModules ?? [])]
|
||||||
const logs = [...new Set(response.plan.requestedLogs ?? [])]
|
const logs = [...new Set(response.plan.requestedLogs ?? [])]
|
||||||
const tools = response.plan.toolRequests ?? []
|
const tools = response.plan.toolRequests ?? []
|
||||||
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
|
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
|
||||||
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
|
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
|
||||||
if (round === maxRounds) return finalize()
|
if (round === maxRounds) {
|
||||||
|
context = fitAgentContext(cachedContext, [], [], tools)
|
||||||
|
return finalize()
|
||||||
|
}
|
||||||
if (tools.length) {
|
if (tools.length) {
|
||||||
if (!executeTool || tools.length > 6) return finalize()
|
if (!executeTool || tools.length > 6) return finalize()
|
||||||
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool)))
|
const fresh = tools.filter(tool => {
|
||||||
if (!fresh.length || toolCount + fresh.length > 24) return finalize()
|
const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
|
||||||
|
return missing && !queriedTools.has(fingerprint(missing))
|
||||||
|
})
|
||||||
|
if (!fresh.length || toolCount + fresh.length > 24) {
|
||||||
|
context = fitAgentContext(cachedContext, [], [], tools)
|
||||||
|
return finalize()
|
||||||
|
}
|
||||||
const results = [...(cachedContext.toolResults ?? [])]
|
const results = [...(cachedContext.toolResults ?? [])]
|
||||||
for (const [index, tool] of fresh.entries()) {
|
for (const [index, proposedTool] of fresh.entries()) {
|
||||||
checkAborted()
|
checkAborted()
|
||||||
|
const tool = missingAgentRead(proposedTool, results)
|
||||||
|
if (!tool) continue
|
||||||
const key = fingerprint(tool)
|
const key = fingerprint(tool)
|
||||||
if (queriedTools.has(key)) continue
|
if (queriedTools.has(key)) continue
|
||||||
queriedTools.add(key)
|
queriedTools.add(key)
|
||||||
@@ -209,8 +248,8 @@ export async function resolveAgentContext({
|
|||||||
toolCount++
|
toolCount++
|
||||||
results.push(result)
|
results.push(result)
|
||||||
}
|
}
|
||||||
cachedContext = { ...cachedContext, toolResults: results }
|
cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
|
||||||
context = fitAgentContext(cachedContext)
|
context = fitAgentContext(cachedContext, [], [], tools)
|
||||||
onProgress?.('조회 결과 분석 중')
|
onProgress?.('조회 결과 분석 중')
|
||||||
// A model may also use legacy source requests; process those on the next pass.
|
// A model may also use legacy source requests; process those on the next pass.
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { AgentConversationMessage } from './useAiAgent'
|
||||||
|
|
||||||
|
export type AgentConversationScope = { workspace: string; createdAt: number }
|
||||||
|
export type AgentConversationContext = {
|
||||||
|
environment: 'local' | 'remote'
|
||||||
|
workspaceTarget: string
|
||||||
|
summary: string
|
||||||
|
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
const RECENT_TURNS = 2
|
||||||
|
const MESSAGE_LIMIT = 700
|
||||||
|
const SUMMARY_LIMIT = 1000
|
||||||
|
|
||||||
|
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
|
||||||
|
|
||||||
|
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
|
||||||
|
if (!environment || !target || !root) return undefined
|
||||||
|
const workspace = JSON.stringify([environment, target, root])
|
||||||
|
return workspace.length <= 1000 ? workspace : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseContext(message: AgentConversationMessage, limit: number): string {
|
||||||
|
const plan = message.plan!
|
||||||
|
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
|
||||||
|
const details = [
|
||||||
|
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
|
||||||
|
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
|
||||||
|
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
|
||||||
|
].join('; ')
|
||||||
|
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
|
||||||
|
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
|
||||||
|
return `${clip(answer.trim(), limit - targets.length)}${targets}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentConversationContext(
|
||||||
|
messages: AgentConversationMessage[],
|
||||||
|
workspace: string | undefined,
|
||||||
|
now = Date.now()
|
||||||
|
): AgentConversationContext | undefined {
|
||||||
|
if (!workspace) return undefined
|
||||||
|
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
|
||||||
|
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
|
||||||
|
let summary = ''
|
||||||
|
let user: AgentConversationMessage | undefined
|
||||||
|
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
|
||||||
|
&& Number.isFinite(message.conversationScope.createdAt)
|
||||||
|
&& message.conversationScope.createdAt <= now
|
||||||
|
&& now - message.conversationScope.createdAt < MAX_AGE_MS
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
user = inScope(message) ? message : undefined
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|
||||||
|
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
|
||||||
|
const previous = turns.at(-1)
|
||||||
|
if (previous?.user === user) previous.assistant = message
|
||||||
|
else turns.push({ user, assistant: message })
|
||||||
|
if (turns.length > RECENT_TURNS) turns.shift()
|
||||||
|
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
|
||||||
|
}
|
||||||
|
if (!turns.length) return undefined
|
||||||
|
const recent = turns.slice(-RECENT_TURNS)
|
||||||
|
return {
|
||||||
|
environment, workspaceTarget, summary,
|
||||||
|
recentMessages: recent.flatMap(turn => [
|
||||||
|
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
|
||||||
|
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||||
|
|
||||||
|
type FileEvidence = {
|
||||||
|
path: string; exists: boolean; content: string; hash: string | null
|
||||||
|
complete: boolean; startLine: number; endLine: number; totalLines: number
|
||||||
|
contentTruncated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
|
||||||
|
if (result.name !== 'files.read' || result.status !== 'ok') return
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(result.data)
|
||||||
|
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|
||||||
|
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
|
||||||
|
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
|
||||||
|
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
|
||||||
|
}
|
||||||
|
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
|
||||||
|
const startLine = data.startLine ?? 1
|
||||||
|
const last = startLine + data.content.split('\n').length - 1
|
||||||
|
// Older clients cannot distinguish a ranged read from a cut final line.
|
||||||
|
const endLine = data.endLine ?? last - Number(result.truncated)
|
||||||
|
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|
||||||
|
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|
||||||
|
|| data.contentTruncated === true && endLine >= last) return
|
||||||
|
return { ...data, startLine, endLine }
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
|
||||||
|
|
||||||
|
// Only combine observations of the same file revision on the same host.
|
||||||
|
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
|
||||||
|
const compacted: AgentToolResult[] = []
|
||||||
|
for (const result of results) {
|
||||||
|
const file = readFileEvidence(result)
|
||||||
|
if (!file) { compacted.push(result); continue }
|
||||||
|
const key = fileKey(result, file)
|
||||||
|
let current = result
|
||||||
|
let currentFile = file
|
||||||
|
for (let index = compacted.length - 1; index >= 0; index--) {
|
||||||
|
const previous = compacted[index]
|
||||||
|
const prior = readFileEvidence(previous)
|
||||||
|
if (!prior || fileKey(previous, prior) !== key) continue
|
||||||
|
if (prior.hash !== file.hash || prior.exists !== file.exists) {
|
||||||
|
compacted.splice(index, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (currentFile.complete) { compacted.splice(index, 1); continue }
|
||||||
|
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
|
||||||
|
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|
||||||
|
|| prior.totalLines !== currentFile.totalLines
|
||||||
|
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
|
||||||
|
const lines = new Map<number, string>()
|
||||||
|
let conflict = false
|
||||||
|
for (const part of [prior, currentFile]) {
|
||||||
|
for (const [offset, line] of part.content.split('\n').entries()) {
|
||||||
|
const number = part.startLine + offset
|
||||||
|
if (number > part.endLine) break
|
||||||
|
if (lines.has(number) && lines.get(number) !== line) conflict = true
|
||||||
|
lines.set(number, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (conflict) continue
|
||||||
|
const startLine = Math.min(prior.startLine, currentFile.startLine)
|
||||||
|
const endLine = Math.max(prior.endLine, currentFile.endLine)
|
||||||
|
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
|
||||||
|
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
|
||||||
|
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
|
||||||
|
const data = JSON.stringify(merged)
|
||||||
|
if (content.length > 40_000 || data.length > 60_000) continue
|
||||||
|
currentFile = merged
|
||||||
|
current = { ...result, data, truncated: true }
|
||||||
|
compacted.splice(index, 1)
|
||||||
|
}
|
||||||
|
compacted.push(current)
|
||||||
|
}
|
||||||
|
return compacted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
|
||||||
|
if (tool.name !== 'files.read') return tool
|
||||||
|
const observations = results.filter(result => result.moduleName === tool.moduleName)
|
||||||
|
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
|
||||||
|
const latest = observations.at(-1)
|
||||||
|
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
|
||||||
|
if (files.some(file => file.complete)) return
|
||||||
|
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
|
||||||
|
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
|
||||||
|
const start = tool.args?.startLine ?? 1
|
||||||
|
const total = files[0]?.totalLines
|
||||||
|
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
|
||||||
|
let next = start
|
||||||
|
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
|
||||||
|
for (const [from, to] of ranges) {
|
||||||
|
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
|
||||||
|
if (to >= next) next = to + 1
|
||||||
|
if (next > end) return
|
||||||
|
}
|
||||||
|
if (next > end) return
|
||||||
|
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeAgentEvidence(result: AgentToolResult): string {
|
||||||
|
const file = readFileEvidence(result)
|
||||||
|
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
|
||||||
|
export type AgentProvider = typeof AGENT_PROVIDERS[number]
|
||||||
|
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
|
||||||
|
export type AgentStatus = {
|
||||||
|
enabled: boolean
|
||||||
|
provider: string | null
|
||||||
|
model: string | null
|
||||||
|
imagesEnabled?: boolean
|
||||||
|
models: AgentModelOption[]
|
||||||
|
selectionSupported: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
|
||||||
|
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
|
||||||
|
const selectionSupported = Array.isArray(value.models)
|
||||||
|
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
|
||||||
|
const models = AGENT_PROVIDERS.flatMap(provider => {
|
||||||
|
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
|
||||||
|
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
|
||||||
|
})
|
||||||
|
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
|
||||||
|
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
|
||||||
|
return models.find(model => model.provider === preference) ?? models[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentModelLabel(model: AgentModelOption): string {
|
||||||
|
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { createAgentSessionSync } from './agentSessionSync'
|
|||||||
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
|
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
|
||||||
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
||||||
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||||
|
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
|
||||||
|
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
|
||||||
|
|
||||||
export type AgentFileChange = {
|
export type AgentFileChange = {
|
||||||
expectedHash?: string | null
|
expectedHash?: string | null
|
||||||
@@ -53,6 +55,7 @@ export type AgentCommandExecution = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentPlan = {
|
export type AgentPlan = {
|
||||||
|
conversationSummary?: string
|
||||||
codeReferences?: AgentCodeReference[]
|
codeReferences?: AgentCodeReference[]
|
||||||
toolRequests?: AgentToolRequest[]
|
toolRequests?: AgentToolRequest[]
|
||||||
evidence?: AgentToolResult[]
|
evidence?: AgentToolResult[]
|
||||||
@@ -87,6 +90,7 @@ export type AgentRuntimeLog = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentConversationMessage = {
|
export type AgentConversationMessage = {
|
||||||
|
conversationScope?: AgentConversationScope
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
@@ -117,13 +121,6 @@ type UseAiAgentOptions = {
|
|||||||
onResponse?: () => void
|
onResponse?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentStatus = {
|
|
||||||
enabled: boolean
|
|
||||||
provider: string | null
|
|
||||||
model: string | null
|
|
||||||
imagesEnabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentSessionResponse = {
|
type AgentSessionResponse = {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
messages: AgentConversationMessage[]
|
messages: AgentConversationMessage[]
|
||||||
@@ -132,7 +129,11 @@ type AgentSessionResponse = {
|
|||||||
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
|
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
|
||||||
|
|
||||||
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
|
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
|
||||||
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null })
|
const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
|
||||||
|
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
|
||||||
|
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
|
||||||
|
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
|
||||||
|
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
|
||||||
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
|
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
|
||||||
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
|
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
|
||||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
@@ -150,6 +151,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
onResponseRef.current = onResponse
|
onResponseRef.current = onResponse
|
||||||
const authenticated = Boolean(accessToken && courseId && studentEmail)
|
const authenticated = Boolean(accessToken && courseId && studentEmail)
|
||||||
|
|
||||||
|
const selectProvider = (provider: string): void => {
|
||||||
|
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
|
||||||
|
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||||
|
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let provider: string | null = null
|
||||||
|
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
|
||||||
|
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||||
|
}, [modelPreferenceKey])
|
||||||
|
|
||||||
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
const authorization = new Headers(options?.headers).get('Authorization')
|
const authorization = new Headers(options?.headers).get('Authorization')
|
||||||
?? `Bearer ${await tokenReaderRef.current()}`
|
?? `Bearer ${await tokenReaderRef.current()}`
|
||||||
@@ -167,7 +180,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
||||||
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
||||||
}
|
}
|
||||||
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
@@ -205,27 +218,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !courseId) {
|
if (!accessToken || !courseId) {
|
||||||
setStatus({ enabled: false, provider: null, model: null })
|
setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||||
setStatusLoading(false)
|
setStatusLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setStatusLoading(true)
|
let revision = 0
|
||||||
|
let initialLoad = true
|
||||||
|
const refresh = (): void => {
|
||||||
|
const requestRevision = ++revision
|
||||||
|
if (initialLoad) setStatusLoading(true)
|
||||||
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
|
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
|
||||||
.then((nextStatus) => {
|
.then(nextStatus => {
|
||||||
if (!cancelled) setStatus(nextStatus)
|
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setStatus({ enabled: false, provider: null, model: null })
|
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setStatusLoading(false)
|
if (!cancelled && requestRevision === revision) {
|
||||||
|
initialLoad = false
|
||||||
|
setStatusLoading(false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
const refreshVisible = (): void => { if (!document.hidden) refresh() }
|
||||||
|
refresh()
|
||||||
|
window.addEventListener('focus', refresh)
|
||||||
|
document.addEventListener('visibilitychange', refreshVisible)
|
||||||
|
const timer = window.setInterval(refreshVisible, 30_000)
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.removeEventListener('focus', refresh)
|
||||||
|
document.removeEventListener('visibilitychange', refreshVisible)
|
||||||
|
window.clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [accessToken, courseId, requestApi, weekNumber])
|
}, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
|
||||||
|
|
||||||
// The workspace stays mounted while other app pages or Q&A tabs are shown.
|
// The workspace stays mounted while other app pages or Q&A tabs are shown.
|
||||||
// Request cancellation follows the conversation lifetime, not its visibility.
|
// Request cancellation follows the conversation lifetime, not its visibility.
|
||||||
@@ -290,19 +319,28 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
options: {
|
options: {
|
||||||
showUserMessage?: boolean
|
showUserMessage?: boolean
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
|
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
|
||||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||||
environment?: 'local' | 'remote'
|
environment?: 'local' | 'remote'
|
||||||
|
workspaceTarget?: string
|
||||||
|
workspaceRoot?: string
|
||||||
toolResults?: AgentToolResult[]
|
toolResults?: AgentToolResult[]
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const request = rawRequest.trim()
|
const request = rawRequest.trim()
|
||||||
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return
|
if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
|
||||||
|
const requestModel = selectedModel
|
||||||
|
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
const workspace = agentConversationWorkspace(options.environment, options.workspaceTarget, options.workspaceRoot)
|
||||||
|
const conversationScope = workspace ? { workspace, createdAt: startedAt } : undefined
|
||||||
|
const conversationContext = buildAgentConversationContext(messageStateRef.current.messages, workspace, startedAt)
|
||||||
const userMessage: AgentConversationMessage = {
|
const userMessage: AgentConversationMessage = {
|
||||||
id: `agent-user-${Date.now()}`,
|
id: `agent-user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: request
|
content: request,
|
||||||
|
...(conversationScope ? { conversationScope } : {})
|
||||||
}
|
}
|
||||||
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||||
setPending(true)
|
setPending(true)
|
||||||
@@ -317,26 +355,34 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
|
||||||
|
if (options.loadInitialContext) {
|
||||||
|
setActivity('모듈 상태 조회 중')
|
||||||
|
const loaded = await options.loadInitialContext(abortController.signal)
|
||||||
|
if (!currentRequest()) return
|
||||||
|
initialContext = { ...initialContext, ...loaded }
|
||||||
|
}
|
||||||
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
|
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
|
||||||
const imageAttachments: AgentImageReference[] = []
|
const imageAttachments: AgentImageReference[] = []
|
||||||
for (const file of options.attachments ?? []) {
|
for (const file of options.attachments ?? []) {
|
||||||
if (file.kind !== 'image') continue
|
if (file.kind !== 'image') continue
|
||||||
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
|
if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
|
||||||
setActivity(`이미지 업로드 중: ${file.name}`)
|
setActivity(`이미지 업로드 중: ${file.name}`)
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('courseId', courseId)
|
body.append('courseId', courseId)
|
||||||
|
if (selection.provider) body.append('provider', selection.provider)
|
||||||
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
||||||
body.append('file', file.file, file.name)
|
body.append('file', file.file, file.name)
|
||||||
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
||||||
|
if (!currentRequest()) return
|
||||||
imageAttachments.push(uploaded)
|
imageAttachments.push(uploaded)
|
||||||
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
|
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
|
||||||
if (abortController.signal.aborted) return
|
|
||||||
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
|
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
|
||||||
}
|
}
|
||||||
const imageTokens = imageAttachments.map(image => image.token)
|
const imageTokens = imageAttachments.map(image => image.token)
|
||||||
setActivity('요청 분석 중')
|
setActivity('요청 분석 중')
|
||||||
const response = await resolveAgentContext({
|
const response = await resolveAgentContext({
|
||||||
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
|
initialContext,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
loadRequestedContext: options.loadRequestedContext,
|
loadRequestedContext: options.loadRequestedContext,
|
||||||
executeTool: options.executeTool,
|
executeTool: options.executeTool,
|
||||||
@@ -345,10 +391,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
|
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (abortController.signal.aborted) return
|
if (!currentRequest()) return
|
||||||
const plan: AgentPlan = {
|
const plan: AgentPlan = {
|
||||||
...response.plan,
|
...response.plan,
|
||||||
requestedModules: response.plan.requestedModules ?? [],
|
requestedModules: response.plan.requestedModules ?? [],
|
||||||
@@ -363,6 +409,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
revealStartedAt: Date.now(),
|
revealStartedAt: Date.now(),
|
||||||
durationMs: Math.max(0, Date.now() - startedAt),
|
durationMs: Math.max(0, Date.now() - startedAt),
|
||||||
content: plan.summary,
|
content: plan.summary,
|
||||||
|
...(conversationScope ? { conversationScope } : {}),
|
||||||
provider: response.provider,
|
provider: response.provider,
|
||||||
model: response.model,
|
model: response.model,
|
||||||
usage: response.usage,
|
usage: response.usage,
|
||||||
@@ -372,7 +419,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
}])
|
}])
|
||||||
onResponseRef.current?.()
|
onResponseRef.current?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (abortController.signal.aborted) return
|
if (!currentRequest()) return
|
||||||
updateMessages((current) => [...current, {
|
updateMessages((current) => [...current, {
|
||||||
id: `agent-error-${Date.now()}`,
|
id: `agent-error-${Date.now()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -460,11 +507,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
||||||
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return
|
if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
|
||||||
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
||||||
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
||||||
if (!reference) return
|
if (!reference) return
|
||||||
const latestMessageId = messageStateRef.current.messages.at(-1)?.id
|
const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
|
||||||
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
||||||
const update = (state: AgentCodeReferenceState): void => {
|
const update = (state: AgentCodeReferenceState): void => {
|
||||||
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
|
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
|
||||||
@@ -472,7 +519,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
navigatingRef.current = true
|
navigatingRef.current = true
|
||||||
update({ status: 'opening' })
|
update({ status: 'opening' })
|
||||||
try {
|
try {
|
||||||
await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current)
|
await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
|
||||||
update({ status: 'opened' })
|
update({ status: 'opened' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
|
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
|
||||||
@@ -504,6 +551,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...status,
|
...status,
|
||||||
|
provider: selectedModel?.provider ?? null,
|
||||||
|
model: selectedModel?.model ?? null,
|
||||||
|
imagesEnabled: selectedModel?.imagesEnabled ?? false,
|
||||||
|
selectProvider,
|
||||||
statusLoading: statusLoading || historyLoading,
|
statusLoading: statusLoading || historyLoading,
|
||||||
pending,
|
pending,
|
||||||
activity,
|
activity,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"에이전트 모델 선택": "Agent model",
|
||||||
|
"사용 가능한 모델 없음": "No models available",
|
||||||
"전송": "Send",
|
"전송": "Send",
|
||||||
"파일 첨부": "Attach files",
|
"파일 첨부": "Attach files",
|
||||||
"첨부 제거": "Remove attachment",
|
"첨부 제거": "Remove attachment",
|
||||||
@@ -739,6 +741,7 @@
|
|||||||
"소스 파일": "Source files",
|
"소스 파일": "Source files",
|
||||||
"{{count}}건": "{{count}} items",
|
"{{count}}건": "{{count}} items",
|
||||||
"요청 분석 중": "Analyzing request",
|
"요청 분석 중": "Analyzing request",
|
||||||
|
"모듈 상태 조회 중": "Checking module status",
|
||||||
"조회 결과 분석 중": "Analyzing retrieved results",
|
"조회 결과 분석 중": "Analyzing retrieved results",
|
||||||
"추가 자료 조회 중": "Retrieving additional context",
|
"추가 자료 조회 중": "Retrieving additional context",
|
||||||
"최종 답변 정리 중": "Preparing the final answer",
|
"최종 답변 정리 중": "Preparing the final answer",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const {test} = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const {createHash} = require('node:crypto')
|
||||||
|
const {spawnSync} = require('node:child_process')
|
||||||
|
|
||||||
|
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
|
||||||
|
assert.ok(fn)
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
|
||||||
|
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
|
||||||
|
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
|
||||||
|
let launch, transfers = 0
|
||||||
|
const context = {
|
||||||
|
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
|
||||||
|
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
|
||||||
|
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
|
||||||
|
slamRosBridgeImage: 'slam:local',
|
||||||
|
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
|
||||||
|
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
|
||||||
|
expandRemotePathVar: () => [], reportVisualizationProgress() {},
|
||||||
|
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
|
||||||
|
transfers++
|
||||||
|
assert.equal(timeout, 30000)
|
||||||
|
assert.ok(input.length > 1000)
|
||||||
|
const {spawn} = require('node:child_process')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
|
||||||
|
let error = ''
|
||||||
|
child.stderr.on('data', data => {error += data})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
|
||||||
|
child.stdin.end(input)
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
runSsh: async (_target, command) => { launch = command }
|
||||||
|
}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
|
||||||
|
try {
|
||||||
|
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
|
||||||
|
assert.equal(transfers, 1)
|
||||||
|
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
|
||||||
|
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
|
||||||
|
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
|
||||||
|
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
|
||||||
|
assert.equal(syntax.status, 0, syntax.stderr)
|
||||||
|
assert.ok(launch.includes('docker run'))
|
||||||
|
} finally {await fs.rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
|
|||||||
}
|
}
|
||||||
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
|
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
|
||||||
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
|
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
|
||||||
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
|
assert.match(run, /--device "\$camera_device:\$camera_device"/);
|
||||||
|
assert.match(run, /CAMERA_DEVICE" = \/dev/);
|
||||||
|
assert.match(run, /mapfile -t CAMERA_DEVICES/);
|
||||||
|
assert.doesNotMatch(run, /-v \/dev:\/dev/);
|
||||||
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
|
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
|
||||||
assert.match(run, /--device "\$capture_device:\$capture_device"/);
|
assert.match(run, /--device "\$capture_device:\$capture_device"/);
|
||||||
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
|
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
|
||||||
|
|||||||
@@ -1,70 +1,103 @@
|
|||||||
const assert = require('node:assert/strict');
|
const { test } = require('node:test')
|
||||||
const fs = require('node:fs');
|
const assert = require('node:assert/strict')
|
||||||
const path = require('node:path');
|
const fs = require('node:fs')
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
|
function harness() {
|
||||||
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
|
const exports = {}, requests = [], revoked = [], pending = new Map()
|
||||||
let now = 10000;
|
let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
|
||||||
let requests = 0;
|
let sequence = 0
|
||||||
let ok = true;
|
function element() {
|
||||||
let visible = false;
|
return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
|
||||||
let afterBlob = () => {};
|
}
|
||||||
const context = vm.createContext({
|
const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
|
||||||
ENABLE_CAMERA_PREVIEW: true,
|
const globals = { exports, document, AbortController, Date: { now: () => now },
|
||||||
cameraFrameRequestInFlight: false,
|
window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
|
||||||
cameraFrameRetryAt: 0,
|
URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
|
||||||
cameraFrameObjectUrl: null,
|
fetch: async (url, options) => {
|
||||||
cameraPreview: {},
|
requests.push({ url, signal: options.signal })
|
||||||
latestSlamState: null,
|
if (pending.has(url)) await pending.get(url)
|
||||||
latestSlamStateReceivedAt: now,
|
return { ok: enabledFetch, blob: async () => ({}) }
|
||||||
document: { hidden: false },
|
}
|
||||||
Date: { now: () => now },
|
}
|
||||||
BACKEND_URL: 'http://localhost:8765',
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
|
||||||
AbortSignal,
|
const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
|
||||||
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
|
return { root, requests, revoked, pending, document, controller,
|
||||||
setCameraPreviewAvailable(value) { visible = value; },
|
state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
|
||||||
async fetch() {
|
settle: () => new Promise(resolve => setImmediate(resolve)) }
|
||||||
requests++;
|
}
|
||||||
return { ok, async blob() { afterBlob(); return {}; } };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
vm.runInContext(source, context);
|
|
||||||
const refresh = () => context.refreshCameraFrame();
|
|
||||||
const available = (value) => {
|
|
||||||
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
|
|
||||||
context.latestSlamStateReceivedAt = now;
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
|
||||||
await refresh();
|
const h = harness()
|
||||||
available(false);
|
h.controller.setEnabled(true)
|
||||||
await refresh();
|
assert.equal(h.requests.length, 0)
|
||||||
assert.equal(requests, 0, 'Absent camera must not fetch frames');
|
h.state({ topic_status: { camera_image: { available: true } } })
|
||||||
available(true);
|
h.controller.refresh(); await h.settle()
|
||||||
await refresh();
|
assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
|
||||||
assert.equal(requests, 1);
|
assert.equal(h.root.children[0].children[1].src, 'blob:1')
|
||||||
assert.equal(context.cameraPreview.src, 'blob:frame');
|
h.document.hidden = true
|
||||||
now += 3001;
|
h.controller.refresh(); await h.settle()
|
||||||
await refresh();
|
assert.equal(h.requests.length, 1)
|
||||||
assert.equal(requests, 1, 'Stale websocket state must stop polling');
|
h.advance(3001)
|
||||||
assert.equal(visible, false);
|
h.controller.refresh()
|
||||||
available(true);
|
assert.equal(h.visible(), false)
|
||||||
ok = false;
|
assert.equal(h.root.children.length, 0)
|
||||||
await refresh();
|
assert.deepEqual(h.revoked, ['blob:1'])
|
||||||
await refresh();
|
})
|
||||||
assert.equal(requests, 2, 'HTTP errors must back off');
|
|
||||||
now += 5000;
|
test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
|
||||||
available(true);
|
const h = harness()
|
||||||
ok = true;
|
h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
|
||||||
afterBlob = () => available(false);
|
h.controller.setEnabled(true); await h.settle()
|
||||||
context.cameraPreview.src = '';
|
assert.equal(h.root.children.length, 2)
|
||||||
await refresh();
|
assert.equal(h.requests.length, 2)
|
||||||
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
|
assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
|
||||||
assert.equal(context.cameraFrameRequestInFlight, false);
|
const first = h.root.children[0].children[1].src
|
||||||
afterBlob = () => {};
|
h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
|
||||||
available(true);
|
h.controller.refresh(); await h.settle()
|
||||||
await refresh();
|
assert.equal(h.root.children.length, 1)
|
||||||
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
|
assert.equal(h.root.children[0].dataset.cameraId, 'video2')
|
||||||
console.log('camera preview polling tests passed');
|
assert.ok(h.revoked.includes(first))
|
||||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
assert.equal(h.visible(), true)
|
||||||
|
h.controller.dispose()
|
||||||
|
assert.equal(h.root.children.length, 0)
|
||||||
|
assert.equal(h.visible(), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
|
||||||
|
const h = harness()
|
||||||
|
let release
|
||||||
|
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
|
||||||
|
h.state({ cameras: [{ id: 'video0', available: true }] })
|
||||||
|
h.controller.setEnabled(true)
|
||||||
|
h.controller.refresh()
|
||||||
|
assert.equal(h.requests.length, 1)
|
||||||
|
h.controller.setEnabled(false)
|
||||||
|
assert.equal(h.requests[0].signal.aborted, true)
|
||||||
|
release(); await h.settle()
|
||||||
|
assert.equal(h.root.children.length, 0)
|
||||||
|
assert.equal(h.visible(), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
|
||||||
|
const h = harness()
|
||||||
|
h.state({ cameras: [{ id: 'video0', available: true }] })
|
||||||
|
h.fail(true)
|
||||||
|
h.controller.setEnabled(true); await h.settle()
|
||||||
|
h.controller.refresh(); await h.settle()
|
||||||
|
assert.equal(h.requests.length, 1)
|
||||||
|
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
|
||||||
|
h.controller.refresh(); await h.settle()
|
||||||
|
assert.equal(h.requests.length, 2)
|
||||||
|
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
|
||||||
|
const h = harness()
|
||||||
|
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
|
||||||
|
h.controller.setEnabled(true); await h.settle()
|
||||||
|
assert.equal(h.requests.length, 8)
|
||||||
|
assert.equal(h.root.children.length, 8)
|
||||||
|
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user