Compare commits
13
Commits
2ba8096a3c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea938a1248 | ||
|
|
0d79b1f706 | ||
|
|
4e7be79926 | ||
|
|
5c7a106413 | ||
|
|
3d8f77d154 | ||
|
|
f217e7db3e | ||
|
|
0dee6a392f | ||
|
|
161dfd507f | ||
|
|
c9a76e7fb3 | ||
|
|
1bd1880d9b | ||
|
|
868e9b0545 | ||
|
|
ee323fe660 | ||
|
|
659b7497fb |
No files matched your search
+87
-59
@@ -5,7 +5,6 @@ import asyncio
|
||||
import traceback
|
||||
|
||||
from temporary import generate_tmp_id
|
||||
from stream import Stream
|
||||
|
||||
# Tool related classes must be moved to a different module
|
||||
|
||||
@@ -36,8 +35,9 @@ class Agent:
|
||||
|
||||
# should be in expert init?
|
||||
self.tools = []
|
||||
self.remotes = []
|
||||
for resource in self.resources:
|
||||
if resource.kind != 'tool': continue
|
||||
if resource.kind == 'tool':
|
||||
if resource.value.get('type') == 'native' and 'name' in resource.value:
|
||||
tool_cls = next((t for t in self.app.tool_classes if t.tool_name() == resource.value['name']), None)
|
||||
if tool_cls:
|
||||
@@ -45,6 +45,15 @@ class Agent:
|
||||
spec = tool.get_spec().clone()
|
||||
spec.name = resource.key
|
||||
self.tools.append(spec)
|
||||
elif resource.kind == 'remote':
|
||||
value = resource.value or {}
|
||||
if isinstance(value, str):
|
||||
value = {'prefix': value}
|
||||
self.remotes.append({
|
||||
'name': resource.key,
|
||||
'prefix': value.get('prefix'),
|
||||
})
|
||||
|
||||
|
||||
def _set_basic_info(self, id, name, user_id):
|
||||
self.id = id
|
||||
@@ -56,6 +65,10 @@ class Agent:
|
||||
agent_run.close()
|
||||
raise NotImplementedError("Subclasses must implement run()")
|
||||
|
||||
# Async generator: constructs a run, executes it, and yields every message
|
||||
# the run emits (raw inference.Message objects: role + content) in arrival
|
||||
# order. No projection: no delta collapsing, no tool filtering, no dropping
|
||||
# of reasoning/usage. Consumers route or accumulate as they see fit.
|
||||
async def prompt(self, instruction, input=None, **kwargs):
|
||||
run = AgentRun.create(
|
||||
self.app,
|
||||
@@ -67,19 +80,48 @@ class Agent:
|
||||
**kwargs,
|
||||
},
|
||||
)
|
||||
out_stream = Stream(self.app)
|
||||
|
||||
def on_output_event(stream):
|
||||
def on_chunk(chunk):
|
||||
out_stream.feed(chunk)
|
||||
stream.listen(on_chunk, lambda: None)
|
||||
def on_done(data):
|
||||
out_stream.close()
|
||||
run.on('output', on_output_event)
|
||||
queue = asyncio.Queue()
|
||||
|
||||
def on_inference(msg):
|
||||
queue.put_nowait(('message', msg))
|
||||
|
||||
def on_done(_data):
|
||||
queue.put_nowait(('done', None))
|
||||
|
||||
# Register listeners before scheduling the task. AgentRun.on replays
|
||||
# past events of that name, so this both avoids a race and guarantees
|
||||
# no early messages are lost.
|
||||
run.on('inference', on_inference)
|
||||
run.on('done', on_done)
|
||||
await self.run(run)
|
||||
|
||||
return out_stream
|
||||
run.task = asyncio.create_task(self.run(run))
|
||||
|
||||
def on_task_done(task):
|
||||
# Safety net: an agent that raises (or forgets run.close()) would
|
||||
# otherwise leave the consumer awaiting forever.
|
||||
queue.put_nowait(('task', task))
|
||||
|
||||
run.task.add_done_callback(on_task_done)
|
||||
|
||||
try:
|
||||
while True:
|
||||
kind, payload = await queue.get()
|
||||
if kind == 'message':
|
||||
yield payload
|
||||
elif kind == 'done':
|
||||
break
|
||||
else: # task
|
||||
if payload.cancelled():
|
||||
break
|
||||
exc = payload.exception()
|
||||
if exc:
|
||||
raise exc
|
||||
break
|
||||
finally:
|
||||
if not run.task.done():
|
||||
run.task.cancel()
|
||||
|
||||
|
||||
async def received_message(self, message):
|
||||
chat = await self.app.get_chat(message.chat_id)
|
||||
@@ -113,13 +155,28 @@ class Agent:
|
||||
},
|
||||
)
|
||||
|
||||
async def on_output_event(stream):
|
||||
print("[agent] run output " + repr(stream))
|
||||
async def on_inference(msg):
|
||||
kwargs = {'content': msg.content}
|
||||
|
||||
if msg.role == 'assistant':
|
||||
# kind and subkind are default
|
||||
pass
|
||||
elif msg.role == 'reasoning':
|
||||
kwargs['kind'] = "log"
|
||||
kwargs['subkind'] = "reasoning"
|
||||
elif msg.role == 'tool':
|
||||
kwargs['kind'] = "log"
|
||||
kwargs['subkind'] = "tool-call"
|
||||
kwargs['content'] = repr(msg.content)
|
||||
|
||||
# Unknown message
|
||||
else: return
|
||||
|
||||
new_message = await self.app.send_chat_message(
|
||||
sender=self.id,
|
||||
chat_id=message.chat_id,
|
||||
thread_id=message.thread_id,
|
||||
content=stream,
|
||||
**kwargs
|
||||
)
|
||||
run.emit_event('chat-message', {
|
||||
'messageId': new_message['id'],
|
||||
@@ -127,41 +184,7 @@ class Agent:
|
||||
'threadId': message.thread_id,
|
||||
})
|
||||
|
||||
async def on_reasoning_event(stream):
|
||||
print("[agent] run output " + repr(stream))
|
||||
new_message = await self.app.send_chat_message(
|
||||
sender=self.id,
|
||||
chat_id=message.chat_id,
|
||||
thread_id=message.thread_id,
|
||||
content=stream,
|
||||
kind="log",
|
||||
subkind="reasoning",
|
||||
)
|
||||
run.emit_event('chat-message', {
|
||||
'messageId': new_message['id'],
|
||||
'chatId': message.chat_id,
|
||||
'threadId': message.thread_id,
|
||||
})
|
||||
|
||||
async def on_toolcall_event(stream):
|
||||
print("[agent] run output " + repr(stream))
|
||||
new_message = await self.app.send_chat_message(
|
||||
sender=self.id,
|
||||
chat_id=message.chat_id,
|
||||
thread_id=message.thread_id,
|
||||
content=stream,
|
||||
kind="log",
|
||||
subkind="tool-call",
|
||||
)
|
||||
run.emit_event('chat-message', {
|
||||
'messageId': new_message['id'],
|
||||
'chatId': message.chat_id,
|
||||
'threadId': message.thread_id,
|
||||
})
|
||||
|
||||
await run.on_async('reasoning', on_reasoning_event)
|
||||
await run.on_async('output', on_output_event)
|
||||
await run.on_async('tool-call', on_toolcall_event)
|
||||
await run.on_async('inference', on_inference)
|
||||
|
||||
await self.run(run)
|
||||
return run
|
||||
@@ -184,16 +207,21 @@ class Agent:
|
||||
if r.kind == kind and (key is None or r.key == key)]
|
||||
|
||||
def get_tools(self, run=None):
|
||||
"""Collect the tools available to this agent.
|
||||
|
||||
Returns the agent-configured tools (derived from its resources) plus
|
||||
any remote tools referenced by the run input. When a run is given,
|
||||
remote names in ``run.input['remotes']`` are resolved and appended.
|
||||
"""
|
||||
tools = list(self.tools)
|
||||
remotes = list(self.remotes)
|
||||
if run is not None and 'remotes' in run.input:
|
||||
tools += self.app.getRemoteTools(self.user_id, run.input['remotes'])
|
||||
return tools
|
||||
remotes += list(run.input['remotes'])
|
||||
|
||||
tools = list(self.tools) + self.app.getRemoteTools(self.user_id, remotes)
|
||||
|
||||
# Dedupe by tool name (agent-configured vs run-input vs native collisions)
|
||||
seen, result = set(), []
|
||||
for tool in tools:
|
||||
if tool.name in seen:
|
||||
continue
|
||||
seen.add(tool.name)
|
||||
result.append(tool)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DebugAgent(Agent):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import inference
|
||||
from agent import Agent, AgentField
|
||||
from tool import ToolSpec
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -20,62 +21,47 @@ async def chat_completion_loop(app, run, messages, model_id=None, tools=None):
|
||||
tools = list(tools or [])
|
||||
execute_tools = run.input.get('tool_policy', 'auto') == 'auto'
|
||||
|
||||
tool_calls = []
|
||||
output_stream = None
|
||||
reasoning_stream = None
|
||||
inference_end = asyncio.Event()
|
||||
|
||||
def on_output(stream):
|
||||
nonlocal output_stream
|
||||
output_stream = stream
|
||||
|
||||
def on_reasoning(stream):
|
||||
nonlocal reasoning_stream
|
||||
reasoning_stream = stream
|
||||
|
||||
run.on('output', on_output)
|
||||
run.on('reasoning', on_reasoning)
|
||||
run.on('tool-call', tool_calls.append)
|
||||
run.on('inference-end', lambda _: inference_end.set())
|
||||
|
||||
while True:
|
||||
# Reset per-iteration state
|
||||
output_stream = None
|
||||
reasoning_stream = None
|
||||
inference_end.clear()
|
||||
|
||||
await inference.chat_completion(
|
||||
tool_calls = []
|
||||
turn_response = False
|
||||
generator = inference.chat_completion(
|
||||
app,
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
agent_run=run,
|
||||
opts=run.input,
|
||||
)
|
||||
|
||||
await inference_end.wait()
|
||||
|
||||
content = output_stream and await output_stream.content_wait()
|
||||
reasoning = reasoning_stream and await reasoning_stream.content_wait()
|
||||
|
||||
if content or reasoning:
|
||||
msg = inference.LlamaMessage('assistant', content or "")
|
||||
if reasoning:
|
||||
msg.reasoning = reasoning
|
||||
pre_len = len(messages)
|
||||
async for msg in generator:
|
||||
messages.append(msg)
|
||||
run.emit_event('inference', msg)
|
||||
if msg.role == 'tool':
|
||||
tool_calls.append(msg.content)
|
||||
if msg.role == 'assistant':
|
||||
turn_response = True
|
||||
|
||||
if execute_tools and tool_calls:
|
||||
# Avoid infinite loops from server failures
|
||||
if len(messages) == pre_len: break
|
||||
|
||||
cancel_tools = False
|
||||
for tc in tool_calls:
|
||||
tool = next((t for t in tools if t.name == tc.tool_name), None)
|
||||
if tool is None:
|
||||
print(f"[agent] unknown tool name: {tc.tool_name}")
|
||||
continue
|
||||
if execute_tools:
|
||||
# Only known 'non-callable' tool spec
|
||||
if isinstance(tool, ToolSpec) and not tool.func:
|
||||
cancel_tools = True
|
||||
tc.tool = tool
|
||||
else:
|
||||
tc.result = await tool.execute_async(**tc.arguments)
|
||||
messages.append(inference.LlamaMessage('tool', tc))
|
||||
tool_calls.clear()
|
||||
# Buffer time to let the provider rest before the next call
|
||||
await asyncio.sleep(2)
|
||||
elif content or not reasoning:
|
||||
break
|
||||
else:
|
||||
tc.tool = tool
|
||||
|
||||
# If not executing tools, respond to end the turn
|
||||
if not (tool_calls and execute_tools) and turn_response or cancel_tools: break
|
||||
|
||||
run.close()
|
||||
|
||||
@@ -105,13 +91,9 @@ class ExpertAgent(Agent):
|
||||
# Provide the agent's default tools unless the run supplies its own.
|
||||
# Only agent-provided tools are executable within the loop.
|
||||
tools = run.input.get("tools", [])
|
||||
if tools:
|
||||
run.input['tool_policy'] = 'ignore'
|
||||
else:
|
||||
if run.input.get('tool_policy') != 'caller':
|
||||
tools += self.get_tools(run)
|
||||
|
||||
print("Final tool object: " + repr(run.input.get('tools', [])))
|
||||
|
||||
# Build the initial message list
|
||||
messages = inference.build_messages(
|
||||
system_prompt=self.config.get("system_prompt", ""),
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@ class App(ChatMixin, AgentMixin):
|
||||
]
|
||||
|
||||
from features.calc import Calc
|
||||
self.tool_classes = [Calc]
|
||||
from features.web import WebSearch, WebFetch
|
||||
self.tool_classes = [Calc, WebSearch, WebFetch]
|
||||
|
||||
# --- Connection Management (Synchronous) ---
|
||||
|
||||
|
||||
@@ -126,13 +126,21 @@ class AgentMixin:
|
||||
|
||||
async def save_agent_resource(self, agent_id, kind, key, value):
|
||||
await db.AgentResource.update_or_create(
|
||||
{'value': value},
|
||||
agent_id=agent_id,
|
||||
kind=kind,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
print(f"Saved agent resource: {agent_id} - {kind} - {key}")
|
||||
|
||||
async def delete_agent_resource(self, agent_id, kind, key):
|
||||
await db.AgentResource.filter(
|
||||
agent_id=agent_id,
|
||||
kind=kind,
|
||||
key=key,
|
||||
).delete()
|
||||
print(f"Deleted agent resource: {agent_id} - {kind} - {key}")
|
||||
|
||||
# --- Control & Actions ---
|
||||
|
||||
async def get_agent_control(self, agent_id):
|
||||
@@ -172,10 +180,47 @@ class AgentMixin:
|
||||
runs = [r for r in runs if r.user_id == user_id]
|
||||
return [r.serialize() for r in runs]
|
||||
|
||||
async def send_agent_prompt(self, agent_id, instruction, input=None, options={}):
|
||||
async def prompt_agent(self, agent_id, instruction, input=None, options={}):
|
||||
# Mirror agent.prompt's generator so run ownership and lifecycle stay
|
||||
# inside the mixin: callers just iterate and receive the same raw
|
||||
# inference.Message objects. get_agent_control is awaited lazily on first
|
||||
# iteration, keeping the control object alive for the generator's
|
||||
# lifetime.
|
||||
|
||||
def normalize_tool(t):
|
||||
# this function makes a lot of sense in ToolSpec itself...
|
||||
from tool import ToolSpec, ToolParameter
|
||||
if isinstance(t, dict):
|
||||
params = []
|
||||
|
||||
d = t.get('parameters', {})
|
||||
required = d.get('required', [])
|
||||
props = d.get('properties', {})
|
||||
|
||||
for key, p in props.items():
|
||||
params.append(ToolParameter(
|
||||
name=key,
|
||||
type=p.get('type'),
|
||||
description=p.get('description'),
|
||||
required=key in required,
|
||||
))
|
||||
return ToolSpec(
|
||||
name=t['name'],
|
||||
description=t['description'],
|
||||
parameters=params,
|
||||
)
|
||||
else:
|
||||
return t
|
||||
|
||||
if 'tools' in options:
|
||||
options = {**options}
|
||||
options['tools'] = [
|
||||
normalize_tool(t) for t in options['tools']
|
||||
]
|
||||
|
||||
control = await self.get_agent_control(agent_id)
|
||||
response = await control.prompt(instruction, input, **options)
|
||||
return response
|
||||
async for msg in control.prompt(instruction, input, **options):
|
||||
yield msg
|
||||
|
||||
async def call_agent_action(self, agent_id, action, args):
|
||||
control = await self.get_agent_control(agent_id)
|
||||
|
||||
@@ -13,6 +13,7 @@ def message_json(msg):
|
||||
"sender": msg.sender,
|
||||
"kind": msg.kind,
|
||||
"subkind": msg.subkind,
|
||||
"format": msg.format,
|
||||
"content": msg.content,
|
||||
"threadId": msg.thread_id,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ async def upload_file(user_id, drive_name, filename, stream, mime_type=None):
|
||||
drive = await db.Drive.create(user_id=user_id, name=drive_name)
|
||||
|
||||
# Check collision
|
||||
if await db.Drive.get_or_none(drive_id=drive.id, filename=filename):
|
||||
if await db.DriveFile.get_or_none(drive_id=drive.id, filename=filename):
|
||||
raise Exception("Duplicated file")
|
||||
|
||||
# 2. Prepare disk path
|
||||
@@ -82,7 +82,7 @@ async def delete_file(file_id):
|
||||
return abs_path
|
||||
|
||||
async def resolve_file(user_id, drive_name, filename):
|
||||
"""Resolve file to absolute disk path after permission check. Returns (path, mime)."""
|
||||
"""Resolve file to absolute disk path after permission check. Returns (abs_path, file.id)."""
|
||||
# Eager load drive and verify ownership
|
||||
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
|
||||
if drive.user_id != user_id:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Web tools: online search and page fetching.
|
||||
|
||||
Both tools subclass :class:`~backend.src.tool.Tool` and rely on the base
|
||||
class's automatic ``ToolSpec`` generation (docstring -> description,
|
||||
``run`` signature -> parameters).
|
||||
"""
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html import unescape
|
||||
|
||||
from tool import Tool
|
||||
|
||||
|
||||
def _strip_html(html: str) -> str:
|
||||
"""Light HTML filter: drop scripts/styles, strip tags, collapse whitespace."""
|
||||
html = re.sub(
|
||||
r"<(script|style|noscript)[^>]*>.*?</\1>",
|
||||
" ",
|
||||
html,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r"<[^>]+>", " ", html)
|
||||
text = unescape(text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
class WebSearch(Tool):
|
||||
"""Search the web with DuckDuckGo. Returns titles, URLs and snippets."""
|
||||
|
||||
ENDPOINT = "https://html.duckduckgo.com/html/"
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Result anchors look like:
|
||||
# <a class="result__a" href="//duckduckgo.com/l/?uddg=...">Title</a>
|
||||
_RESULT_RE = re.compile(
|
||||
r'<a[^>]*class="[^"]*\bresult__a\b[^"]*"[^>]*href="(?P<href>[^"]+)"[^>]*>'
|
||||
r"(?P<title>.*?)</a>",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SNIPPET_RE = re.compile(
|
||||
r'<a[^>]*class="[^"]*\bresult__snippet\b[^"]*"[^>]*>(?P<snippet>.*?)</a>',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def run(self, query: str, max_results: int = 5):
|
||||
body = urllib.parse.urlencode({"q": query}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self.ENDPOINT,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "text/html",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
page = resp.read().decode("utf-8", errors="replace")
|
||||
|
||||
snippets = [m.group("snippet") for m in self._SNIPPET_RE.finditer(page)]
|
||||
|
||||
results = []
|
||||
for i, match in enumerate(self._RESULT_RE.finditer(page)):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
results.append({
|
||||
"title": self._clean(match.group("title")),
|
||||
"url": self._unwrap(match.group("href")),
|
||||
"snippet": self._clean(snippets[i]) if i < len(snippets) else "",
|
||||
})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _clean(value: str) -> str:
|
||||
return unescape(re.sub(r"<[^>]+>", "", value)).strip()
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(href: str) -> str:
|
||||
# DDG wraps result links as //duckduckgo.com/l/?uddg=<url-encoded-target>
|
||||
if "uddg=" in href:
|
||||
target = urllib.parse.parse_qs(urllib.parse.urlparse(href).query).get(
|
||||
"uddg", [""]
|
||||
)[0]
|
||||
if target:
|
||||
return target
|
||||
if href.startswith("//"):
|
||||
return "https:" + href
|
||||
return href
|
||||
|
||||
|
||||
class WebFetch(Tool):
|
||||
"""Fetch a web page and return its text content, lightly filtered."""
|
||||
|
||||
def run(self, url: str):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
html = resp.read().decode("utf-8", errors="replace")
|
||||
return _strip_html(html)
|
||||
|
||||
|
||||
__all__ = ["WebSearch", "WebFetch"]
|
||||
+201
-164
@@ -4,70 +4,121 @@ import json
|
||||
import os
|
||||
import traceback
|
||||
import random
|
||||
import inspect
|
||||
from stream import Stream
|
||||
from tool import format_tools_for_engine, build_tool_prompt_prefix, ToolSpec, ToolCall
|
||||
from tool import Tool, ToolSpec, ToolCall
|
||||
|
||||
#from llama_cpp import Llama
|
||||
from openrouter import OpenRouter
|
||||
|
||||
VALID_ROLES = ['user', 'assistant', 'system', 'tool']
|
||||
# TODO: Collect usage data
|
||||
|
||||
TEST_MOCK = False
|
||||
VALID_ROLES = ['user', 'assistant', 'system', 'tool', 'reasoning', 'usage']
|
||||
CONTEXT_WINDOW = 10_000
|
||||
|
||||
# TODO: Rename to ChatMessage. Add methods for constructing from instruct/openai/etc formatted objects.
|
||||
class LlamaMessage:
|
||||
def __init__(self, role, content, reasoning=None):
|
||||
# Background inference tasks are kept referenced here so they aren't GC'd mid-flight.
|
||||
_background_tasks = set()
|
||||
|
||||
def spawn(coro):
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
return task
|
||||
|
||||
# These reflect "emitted messages" from any actor, individual turn parts, rather than entire turns.
|
||||
# role acts as a type and a sender simultaneously, and content is dynamic
|
||||
class Message:
|
||||
def __init__(self, role, content):
|
||||
if role not in VALID_ROLES:
|
||||
raise Exception("message role must be one of: " + ",".join(VALID_ROLES))
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.reasoning = reasoning
|
||||
|
||||
def to_dict(self):
|
||||
"""Serialize to the dict format expected by llama-cpp / ollama."""
|
||||
d = {"role": self.role, "content": self.content}
|
||||
if self.reasoning:
|
||||
d["reasoning"] = self.reasoning
|
||||
return d
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
def __repr__(self):
|
||||
return f"LlamaMessage(role={self.role!r}, content={self.content!r}, reasoning={self.reasoning!r})"
|
||||
return f"Message(role={self.role!r}, content={self.content!r})"
|
||||
|
||||
class Models:
|
||||
llama_8b = "meta/meta-llama-3-8b-instruct"
|
||||
llama_70b = "meta/meta-llama-3-70b-instruct"
|
||||
qwen3_8b = "lucataco/qwen3-vl-8b-instruct:39e893666996acf464cff75688ad49ac95ef54e9f1c688fbc677330acc478e11"
|
||||
qwen3_235b = "qwen/qwen3-235b-a22b-instruct-2507"
|
||||
def format_tools(tools):
|
||||
specs = []
|
||||
for t in tools:
|
||||
if isinstance(t, dict):
|
||||
specs.append(t)
|
||||
elif isinstance(t, ToolSpec):
|
||||
specs.append(t.to_openai_dict())
|
||||
elif isinstance(t, Tool):
|
||||
specs.append(t.get_spec().to_openai_dict())
|
||||
return specs
|
||||
|
||||
def format_openai(messages):
|
||||
formatted = []
|
||||
last_assistant_message = None
|
||||
def last_assistant_message():
|
||||
if formatted and formatted[-1]['role'] == 'assistant':
|
||||
return formatted[-1]
|
||||
else:
|
||||
msg = {'role': 'assistant', 'content': ''}
|
||||
formatted.append(msg)
|
||||
return msg
|
||||
|
||||
# For debugging
|
||||
_stream_count = 0
|
||||
_badrole_count = 0
|
||||
_badroles = set()
|
||||
|
||||
for msg in messages:
|
||||
content = msg.content
|
||||
if isinstance(content, Stream):
|
||||
_stream_count += 1
|
||||
content = content.content
|
||||
|
||||
if msg.role == 'tool':
|
||||
if not last_assistant_message:
|
||||
# Create one implicitly
|
||||
last_assistant_message = {'role': 'assistant', 'content': ''}
|
||||
tool_calls = last_assistant_message.get('tool_calls')
|
||||
last = last_assistant_message()
|
||||
tool_calls = last.get('tool_calls')
|
||||
if tool_calls == None:
|
||||
tool_calls = []
|
||||
last_assistant_message['tool_calls'] = tool_calls
|
||||
last['tool_calls'] = tool_calls
|
||||
tc = content
|
||||
if isinstance(tc, dict):
|
||||
tc = ToolCall(
|
||||
tool_name=tc['tool_name'],
|
||||
arguments=tc['arguments'],
|
||||
result=tc['result'],
|
||||
id=tc['id']
|
||||
)
|
||||
# Skip non-resolved calls
|
||||
if not tc.result: continue
|
||||
# TODO: Fix call id if missing
|
||||
tool_calls.append({
|
||||
'id': msg.content.id,
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': msg.content.tool_name,
|
||||
'arguments': json.dumps(msg.content.arguments),
|
||||
'name': tc.tool_name,
|
||||
'arguments': json.dumps(tc.arguments),
|
||||
},
|
||||
})
|
||||
formatted.append({
|
||||
'role': 'tool',
|
||||
'tool_call_id': msg.content.id,
|
||||
'content': json.dumps(msg.content.result),
|
||||
'tool_call_id': tc.id,
|
||||
'content': json.dumps(tc.result),
|
||||
})
|
||||
else:
|
||||
fmsg = {'role': msg.role, 'content': msg.content}
|
||||
elif msg.role == 'reasoning':
|
||||
last = last_assistant_message()
|
||||
reasoning = last.get('reasoning', '')
|
||||
last['reasoning'] = reasoning + content
|
||||
elif msg.role in ['user', 'assistant', 'system']:
|
||||
fmsg = {'role': msg.role, 'content': content}
|
||||
formatted.append(fmsg)
|
||||
if msg.role == 'assistant':
|
||||
last_assistant_message = fmsg
|
||||
else:
|
||||
_badrole_count += 1
|
||||
_badroles.add(msg.role)
|
||||
|
||||
_warnings = []
|
||||
if _stream_count: _warnings.append(f"{_stream_count} streams in message list")
|
||||
if _badrole_count: _warnings.append(f"{_badrole_count} messages with bad roles {_badroles}")
|
||||
if _warnings: print(f"[inference] WARNING: " + ", ".join(_warnings))
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
@@ -81,9 +132,12 @@ def safe_queue(callback):
|
||||
queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
async def read_loop():
|
||||
nonlocal callback
|
||||
while True:
|
||||
args = await queue.get()
|
||||
await callback(*args)
|
||||
task = callback(*args)
|
||||
if inspect.isawaitable(task):
|
||||
await task
|
||||
loop.create_task(read_loop())
|
||||
|
||||
def send(*args):
|
||||
@@ -116,10 +170,10 @@ def get_llama_cpp_model(app, model_file):
|
||||
# Generate a conversation based on agent inputs
|
||||
def build_messages(system_prompt="", instruction=None, input=None, messages=None):
|
||||
def normalize(message):
|
||||
if isinstance(message, LlamaMessage):
|
||||
if isinstance(message, Message):
|
||||
return message
|
||||
elif isinstance(message, dict):
|
||||
return LlamaMessage(message['role'], message['content'])
|
||||
return Message(message['role'], message['content'])
|
||||
else:
|
||||
raise Exception("Unsupported message type: " + repr(type(message)))
|
||||
|
||||
@@ -139,9 +193,9 @@ def build_messages(system_prompt="", instruction=None, input=None, messages=None
|
||||
|
||||
# Add default messages to list
|
||||
if system_prompt:
|
||||
messages.insert(0, LlamaMessage("system", system_prompt))
|
||||
messages.insert(0, Message("system", system_prompt))
|
||||
if prompt:
|
||||
messages.append(LlamaMessage("user", prompt))
|
||||
messages.append(Message("user", prompt))
|
||||
|
||||
return messages
|
||||
|
||||
@@ -151,47 +205,38 @@ async def chat_completion(app,
|
||||
messages=None,
|
||||
tools=[],
|
||||
opts={},
|
||||
agent_run=None
|
||||
):
|
||||
# TESTING OVERRIDE
|
||||
# return await mock_completion(app, agent_run)
|
||||
db_model = (await app.get_model(model_id)).value
|
||||
|
||||
model_engine = db_model['engine']
|
||||
model_name = db_model['model']
|
||||
|
||||
if model_engine == "llama-cpp":
|
||||
result = await local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
|
||||
elif model_engine == "openrouter":
|
||||
result = await openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
|
||||
else:
|
||||
raise Exception("Unsupported model: " + repr(model))
|
||||
print(f"[inference] chat completion: {model_engine} '{model_name}', {len(messages)} messages, {len(tools)} tools")
|
||||
|
||||
if agent_run:
|
||||
return None
|
||||
return result
|
||||
if TEST_MOCK:
|
||||
generator = mock_completion(app)
|
||||
elif model_engine == "llama-cpp":
|
||||
generator = local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
|
||||
elif model_engine == "openrouter":
|
||||
generator = openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
|
||||
else:
|
||||
raise Exception("Unsupported model engine: " + repr(model_engine))
|
||||
|
||||
async for message in generator:
|
||||
yield message
|
||||
|
||||
print(f"[inference] chat completion end: {model_engine} '{model_name}'")
|
||||
|
||||
|
||||
# llama.cpp
|
||||
async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
|
||||
if not agent_run:
|
||||
raise Exception("agent_run required")
|
||||
async def local_chat_completion(app, messages, model, tools=[], opts={}):
|
||||
queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
emit = safe_queue(queue.put_nowait)
|
||||
|
||||
content_stream = None
|
||||
async def message_loop(name, value=None):
|
||||
nonlocal content_stream, agent_run
|
||||
if name == 'output':
|
||||
if content_stream == None:
|
||||
content_stream = Stream(app)
|
||||
agent_run.emit_event('output', content_stream)
|
||||
content_stream.feed(value)
|
||||
elif name == 'close':
|
||||
if content_stream:
|
||||
content_stream.close()
|
||||
agent_run.emit_event('inference-end', {})
|
||||
send_message = safe_queue(message_loop)
|
||||
|
||||
def worker():
|
||||
async def worker():
|
||||
stream = None
|
||||
try:
|
||||
llm = get_llama_cpp_model(app, model)
|
||||
|
||||
kwargs = {}
|
||||
@@ -205,56 +250,59 @@ async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_r
|
||||
kwargs['repeat_penalty'] = repeat
|
||||
|
||||
# Format tools for llama-cpp (OpenAI-compatible function format)
|
||||
formatted_tools = format_tools_for_engine(tools, "llama-cpp") if tools else None
|
||||
formatted_tools = format_tools(tools) if tools else None
|
||||
if formatted_tools:
|
||||
kwargs['tools'] = formatted_tools
|
||||
kwargs['tool_choice'] = 'auto'
|
||||
|
||||
# With stream=True, returns an iterator that infers tokens on demand (openai compatible)
|
||||
llm_output = llm.create_chat_completion(
|
||||
iterator = await loop.run_in_executor(None, lambda: llm.create_chat_completion(
|
||||
messages=[msg.to_dict() for msg in messages],
|
||||
stream=True,
|
||||
**kwargs
|
||||
)
|
||||
))
|
||||
|
||||
for chunk in llm_output:
|
||||
# Check for cancellation
|
||||
if agent_run and agent_run.cancelled:
|
||||
print(f"Cancelling local inference for run {agent_run.id}")
|
||||
sentinel = object()
|
||||
while True:
|
||||
chunk = await loop.run_in_executor(None, next, iterator, sentinel)
|
||||
if chunk is sentinel:
|
||||
break
|
||||
|
||||
delta = chunk['choices'][0]['delta']
|
||||
if 'content' in delta:
|
||||
send_message('output', delta['content'])
|
||||
# Handle tool_call deltas if the model emits them
|
||||
if stream is None:
|
||||
stream = Stream(app)
|
||||
emit(Message('assistant', stream))
|
||||
stream.feed(delta['content'])
|
||||
if 'tool_calls' in delta:
|
||||
# Forward tool call chunks as JSON strings for the consumer
|
||||
send_message("tool-call", {
|
||||
"id": tc.get("id"),
|
||||
"function": tc.get("function"),
|
||||
})
|
||||
for tc in delta['tool_calls']:
|
||||
emit(Message('tool', tc))
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
finally:
|
||||
if stream: stream.close()
|
||||
emit(None)
|
||||
|
||||
send_message('close')
|
||||
spawn(worker())
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
if agent_run:
|
||||
agent_run.thread = thread
|
||||
thread.start()
|
||||
|
||||
if agent_run:
|
||||
return None
|
||||
return content_stream
|
||||
while True:
|
||||
message = await queue.get()
|
||||
if message is None:
|
||||
break
|
||||
yield message
|
||||
|
||||
|
||||
async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
|
||||
if not agent_run:
|
||||
raise Exception("agent_run required")
|
||||
async def openrouter_chat_completion(app, messages, model, tools=[], opts={}):
|
||||
if not app.openrouter_client:
|
||||
raise Exception("OPENROUTER_API_KEY environment variable is not set")
|
||||
|
||||
# TODO: Separate as InferenceRun class
|
||||
queue = asyncio.Queue()
|
||||
|
||||
async def worker():
|
||||
content_stream = None
|
||||
reasoning_stream = None
|
||||
|
||||
last_channel = None
|
||||
|
||||
def open_channel(channel):
|
||||
nonlocal last_channel, content_stream, reasoning_stream
|
||||
if channel != last_channel:
|
||||
@@ -264,33 +312,7 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
|
||||
reasoning_stream = None
|
||||
last_channel = channel
|
||||
|
||||
|
||||
async def message_loop(name, value=None):
|
||||
nonlocal content_stream, reasoning_stream, agent_run
|
||||
open_channel(name) # Hack: use the message as the channel name
|
||||
if name == 'output':
|
||||
if content_stream == None:
|
||||
content_stream = Stream(app)
|
||||
agent_run.emit_event('output', content_stream)
|
||||
content_stream.feed(value)
|
||||
if name == 'reasoning':
|
||||
if reasoning_stream == None:
|
||||
reasoning_stream = Stream(app)
|
||||
agent_run.emit_event('reasoning', reasoning_stream)
|
||||
reasoning_stream.feed(value)
|
||||
if name == 'tool-call':
|
||||
agent_run.emit_event('tool-call', value)
|
||||
elif name == 'close':
|
||||
if content_stream: content_stream.close()
|
||||
if reasoning_stream: reasoning_stream.close()
|
||||
agent_run.emit_event('inference-end', {})
|
||||
emit = safe_queue(message_loop)
|
||||
|
||||
async def worker():
|
||||
print("START OPENROUTER INFERENCE")
|
||||
if not app.openrouter_client:
|
||||
raise Exception("OPENROUTER_API_KEY environment variable is not set")
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
'model': model,
|
||||
'messages': format_openai(messages),
|
||||
@@ -301,15 +323,15 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
|
||||
}
|
||||
|
||||
# Pass tools in OpenAI-compatible format if provided
|
||||
formatted_tools = format_tools_for_engine(tools, "openrouter") if tools else None
|
||||
formatted_tools = format_tools(tools) if tools else None
|
||||
if formatted_tools:
|
||||
kwargs['tools'] = formatted_tools
|
||||
|
||||
temp = agent_run.input.get("temperature")
|
||||
temp = opts.get("temperature")
|
||||
if temp is not None:
|
||||
kwargs['temperature'] = temp
|
||||
|
||||
reasoning = agent_run.input.get("reasoning_effort")
|
||||
reasoning = opts.get("reasoning_effort")
|
||||
if reasoning is not None:
|
||||
kwargs['reasoning'] = {"effort": reasoning}
|
||||
|
||||
@@ -320,19 +342,22 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
|
||||
tool_calls = []
|
||||
|
||||
async for event in stream_response:
|
||||
if agent_run and agent_run.cancelled:
|
||||
stream_response.cancel()
|
||||
break
|
||||
|
||||
if event.choices and len(event.choices) > 0:
|
||||
delta = event.choices[0].delta
|
||||
|
||||
if delta.content:
|
||||
emit('output', delta.content)
|
||||
if delta.reasoning:
|
||||
emit('reasoning', delta.reasoning)
|
||||
open_channel('reasoning')
|
||||
if reasoning_stream is None:
|
||||
reasoning_stream = Stream(app)
|
||||
queue.put_nowait(Message('reasoning', reasoning_stream))
|
||||
reasoning_stream.feed(delta.reasoning)
|
||||
if delta.content:
|
||||
open_channel('output')
|
||||
if content_stream is None:
|
||||
content_stream = Stream(app)
|
||||
queue.put_nowait(Message('assistant', content_stream))
|
||||
content_stream.feed(delta.content)
|
||||
if delta.tool_calls:
|
||||
print("[openrouter] tool call:" + repr(delta.tool_calls))
|
||||
for tc in delta.tool_calls:
|
||||
if tc.index >= len(tool_calls):
|
||||
tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
|
||||
@@ -346,36 +371,40 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
|
||||
tool_call.tool_name += tc.function.name
|
||||
if tc.function.arguments:
|
||||
tool_call.arguments += tc.function.arguments
|
||||
else:
|
||||
print("[openrouter] no event choices: " + repr(event))
|
||||
if event.usage:
|
||||
usage = {}
|
||||
for key in ['completion_tokens', 'prompt_tokens', 'total_tokens', 'cost']:
|
||||
if hasattr(event.usage, key):
|
||||
value = getattr(event.usage, key)
|
||||
if value != None:
|
||||
usage[key] = value
|
||||
queue.put_nowait(Message('usage', usage))
|
||||
|
||||
if content_stream: content_stream.close()
|
||||
if reasoning_stream: reasoning_stream.close()
|
||||
|
||||
for tc in tool_calls:
|
||||
try:
|
||||
arguments = json.loads(tc.arguments)
|
||||
tc.arguments = arguments
|
||||
emit('tool-call', tc)
|
||||
tc.arguments = json.loads(tc.arguments)
|
||||
queue.put_nowait(Message('tool', tc))
|
||||
except Exception:
|
||||
print("[openrouter] Bad tool call arguments for " + repr(tc))
|
||||
print(traceback.format_exc())
|
||||
|
||||
emit('close')
|
||||
print("END OPENROUTER INFERENCE")
|
||||
|
||||
def handle_done(task):
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
print("[openrouter] inference cancelled")
|
||||
except Exception as e:
|
||||
print(f"[openrouter] Worker error: {e}")
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
finally:
|
||||
queue.put_nowait(None)
|
||||
|
||||
task = asyncio.create_task(worker())
|
||||
task.add_done_callback(handle_done)
|
||||
agent_run.task = task
|
||||
spawn(worker())
|
||||
|
||||
while True:
|
||||
message = await queue.get()
|
||||
if message is None:
|
||||
break
|
||||
yield message
|
||||
|
||||
|
||||
async def mock_completion(app, agent_run=None):
|
||||
async def mock_completion(app):
|
||||
VERBS = ['fix','scan','sync','deploy','parse','render','cache','trace',
|
||||
'merge','purge','index','probe','flush','bind','warm','drain']
|
||||
OBJECTS = ['nodes','packets','tokens','frames','shards','clusters',
|
||||
@@ -383,20 +412,28 @@ async def mock_completion(app, agent_run=None):
|
||||
'handles','slots','cursors','segments']
|
||||
DELAY = 0.3
|
||||
|
||||
content_stream = Stream(app)
|
||||
if agent_run:
|
||||
agent_run.emit_event('output', content_stream)
|
||||
queue = asyncio.Queue()
|
||||
emit = safe_queue(queue.put_nowait)
|
||||
|
||||
async def content_task ():
|
||||
async def worker():
|
||||
stream = Stream(app)
|
||||
emit(Message('assistant', stream))
|
||||
try:
|
||||
await asyncio.sleep(DELAY)
|
||||
content_stream.feed(random.choice(VERBS))
|
||||
stream.feed(random.choice(VERBS))
|
||||
await asyncio.sleep(DELAY)
|
||||
content_stream.feed(f" {random.randint(1, 99)} ")
|
||||
stream.feed(f" {random.randint(1, 99)} ")
|
||||
await asyncio.sleep(DELAY)
|
||||
content_stream.feed(random.choice(VERBS))
|
||||
stream.feed(random.choice(VERBS))
|
||||
await asyncio.sleep(DELAY)
|
||||
content_stream.close()
|
||||
agent_run.emit_event('inference-end', {})
|
||||
finally:
|
||||
stream.close()
|
||||
emit(None)
|
||||
|
||||
asyncio.create_task(content_task())
|
||||
if not agent_run: return content_stream
|
||||
spawn(worker())
|
||||
|
||||
while True:
|
||||
message = await queue.get()
|
||||
if message is None:
|
||||
break
|
||||
yield message
|
||||
@@ -1,8 +1,7 @@
|
||||
import json, time, traceback, asyncio, uuid
|
||||
from quart import Response, request
|
||||
from quart_auth import login_required, current_user
|
||||
from tool import ToolSpec, ToolParameter
|
||||
from agent import AgentRun
|
||||
from tool import ToolSpec, ToolParameter, ToolCall
|
||||
from utils import encode_json
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,66 +48,72 @@ def route_tool_call(call, tool_names):
|
||||
return deferred
|
||||
|
||||
|
||||
async def execute_deferred(function_call):
|
||||
async def execute_deferred(tc):
|
||||
"""Execute a deferred (native) tool call.
|
||||
|
||||
The output must be stored on the call itself (``function_call.result``).
|
||||
The output must be stored on the call itself (``tc.result``).
|
||||
|
||||
TODO: resolve the ToolSpec for ``function_call.tool_name`` from the
|
||||
TODO: resolve the ToolSpec for ``tc.tool_name`` from the
|
||||
agent's native tools and dispatch through the existing tool
|
||||
infrastructure (ToolSpec.execute_async), recording the result or error on
|
||||
the call. Deferred execution needs more design work; stubbed for now.
|
||||
"""
|
||||
pass
|
||||
tc.result = await tc.tool.execute_async(**tc.arguments)
|
||||
|
||||
|
||||
def attach_run_handlers(run, tool_names):
|
||||
"""Wire the run's output events into a single queue.
|
||||
def messages_to_queue(generator, tool_names):
|
||||
"""Drive a prompt generator into a single queue.
|
||||
|
||||
The queue receives ('content'|'reasoning'|'tool_call'|'deferred_call', data)
|
||||
tuples and terminates with ('eof', None). Tool calls are routed (client vs
|
||||
native/deferred) here so both response modes share the same logic.
|
||||
native/deferred) here so both response modes share the same logic. The
|
||||
returned ``state`` dict tracks whether any tool call was observed, which
|
||||
drives ``finish_reason``.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
state = {'tool_call': False, 'error': None}
|
||||
|
||||
def on_output(stream):
|
||||
async def on_chunk(chunk):
|
||||
async def on_content(chunk):
|
||||
await queue.put(('content', chunk))
|
||||
async def on_eof():
|
||||
# on_done doesn't work?
|
||||
await queue.put(('eof', None))
|
||||
stream.listen(on_chunk, on_eof)
|
||||
|
||||
def on_reasoning(stream):
|
||||
async def on_chunk(chunk):
|
||||
async def on_reasoning(chunk):
|
||||
await queue.put(('reasoning', chunk))
|
||||
async def on_eof(): pass
|
||||
stream.listen(on_chunk, on_eof)
|
||||
def on_eof(): pass
|
||||
|
||||
def on_tool_call(tool_call):
|
||||
deferred = route_tool_call(tool_call, tool_names)
|
||||
async def drain():
|
||||
try:
|
||||
async for msg in generator:
|
||||
# assistant and reasoning are assumed to have a Stream
|
||||
# tool is assumed to have a ToolCall
|
||||
if msg.role == 'assistant':
|
||||
msg.content.listen(on_content, on_eof)
|
||||
elif msg.role == 'reasoning':
|
||||
msg.content.listen(on_reasoning, on_eof)
|
||||
elif msg.role == 'tool':
|
||||
deferred = route_tool_call(msg.content, tool_names)
|
||||
msg_type = 'deferred_call' if deferred else 'tool_call'
|
||||
loop.create_task(queue.put((msg_type, tool_call)))
|
||||
state['tool_call'] = True
|
||||
await queue.put((msg_type, msg.content))
|
||||
elif msg.role == 'usage':
|
||||
await queue.put(('usage', msg.content))
|
||||
except Exception as e:
|
||||
# Surface the failure to the consumer via the eof drain.
|
||||
state['error'] = e
|
||||
finally:
|
||||
await queue.put(('eof', None))
|
||||
|
||||
def on_done(output):
|
||||
loop.create_task(queue.put(('eof', None)))
|
||||
|
||||
run.on('output', on_output)
|
||||
run.on('reasoning', on_reasoning)
|
||||
run.on('tool-call', on_tool_call)
|
||||
run.on('done', on_done)
|
||||
|
||||
return queue
|
||||
loop.create_task(drain())
|
||||
return queue, state
|
||||
|
||||
|
||||
def format_chunk(agent_id, delta, finish_reason=None):
|
||||
def format_chunk(agent_id, delta, finish_reason=None, usage=None):
|
||||
"""Build one OpenAI-format streaming chunk body."""
|
||||
return {
|
||||
"id": "chatcmpl-" + str(time.time()),
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": agent_id,
|
||||
"usage": usage,
|
||||
"choices": [{"delta": delta, "index": 0, "finish_reason": finish_reason}]
|
||||
}
|
||||
|
||||
@@ -124,8 +129,8 @@ def register(server, app):
|
||||
|
||||
# Extract required fields
|
||||
agent_id = data.get("model")
|
||||
openai_messages = data.get("messages", [])
|
||||
openai_tools = data.get("tools", [])
|
||||
openai_messages = data.get("messages") or []
|
||||
openai_tools = data.get("tools") or []
|
||||
stream_mode = data.get("stream", False)
|
||||
|
||||
if not agent_id or not openai_messages:
|
||||
@@ -164,20 +169,36 @@ def register(server, app):
|
||||
# message carries the output.
|
||||
call.result = content
|
||||
entry['resolved'] = True
|
||||
messages.append({'role': 'tool', 'content': call.result, 'tool_call_id': call.id})
|
||||
messages.append({'role': 'tool', 'content': call})
|
||||
continue
|
||||
|
||||
if role == 'assistant' and msg.get('tool_calls'):
|
||||
# Rebuild the assistant's tool call turn from the cache so
|
||||
# unknown or expired calls are silently dropped and the agent
|
||||
# receives the original call objects, which carry their own
|
||||
# output once resolved.
|
||||
# Rebuild the assistant's tool call turn from the request.
|
||||
# Calls already in the cache (persistent conversation) are
|
||||
# reused as-is. Client-declared function calls carry their
|
||||
# full payload on the wire, so they are reconstructed from
|
||||
# the message when the cache is gone. Server-originated
|
||||
# (deferred) calls only ever sent an id + type, so they can
|
||||
# only be recovered from the cache and are dropped otherwise.
|
||||
calls = []
|
||||
for tc in msg['tool_calls']:
|
||||
entry = tool_call_cache.get(tc.get('id', ''))
|
||||
if entry is None:
|
||||
continue
|
||||
call_id = tc.get('id', '')
|
||||
entry = tool_call_cache.get(call_id)
|
||||
if entry is not None:
|
||||
calls.append(entry['call'])
|
||||
continue
|
||||
|
||||
func = tc.get('function')
|
||||
if tc.get('type') == 'function' and func:
|
||||
call = ToolCall(
|
||||
id=call_id,
|
||||
tool_name=func.get('name', ''),
|
||||
arguments=json.loads(func.get('arguments') or '{}'),
|
||||
)
|
||||
# Seeding the cache lets the following role='tool'
|
||||
# message resolve the call and attach its result.
|
||||
cache_tool_call(call, deferred=False)
|
||||
calls.append(call)
|
||||
messages.append({
|
||||
'role': 'assistant',
|
||||
'content': content if type(content) == str else None,
|
||||
@@ -226,30 +247,30 @@ def register(server, app):
|
||||
# tool and gets deferred.
|
||||
tool_names = {spec.name for spec in tools}
|
||||
|
||||
# --- Get agent control and create AgentRun directly ---
|
||||
# --- Get agent control and drive the run through the shared prompt generator ---
|
||||
control = await app.get_agent_control(agent_id)
|
||||
|
||||
run = AgentRun.create(
|
||||
app,
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
input={
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
# The OpenAI inputs ride as kwargs so they land in run.input, which
|
||||
# ExpertAgent.run reads ("tools", "tool_policy", "messages").
|
||||
generator = control.prompt(
|
||||
None,
|
||||
input=None,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
# Never execute tools inside the run: emit them instead so
|
||||
# the handler can send client tools back and defer native
|
||||
# ones.
|
||||
"defer_tool_execution": True,
|
||||
},
|
||||
tool_policy="ignore",
|
||||
)
|
||||
print("run input: " + repr(run.input))
|
||||
|
||||
queue = attach_run_handlers(run, tool_names)
|
||||
await control.run(run)
|
||||
queue, state = messages_to_queue(generator, tool_names)
|
||||
|
||||
usage = {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0, 'cost': 0}
|
||||
|
||||
# --- Streaming ---
|
||||
if stream_mode:
|
||||
async def generate():
|
||||
nonlocal usage
|
||||
try:
|
||||
tool_call_count = 0
|
||||
while True:
|
||||
@@ -281,19 +302,26 @@ def register(server, app):
|
||||
"type": "tool",
|
||||
}]}
|
||||
|
||||
elif msg_type == 'usage':
|
||||
usage = data
|
||||
|
||||
elif msg_type == 'eof':
|
||||
if state['error']:
|
||||
raise state['error']
|
||||
# Determine finish_reason — was there a tool call?
|
||||
finish_reason = "tool_calls" if any(
|
||||
e['name'] == 'tool_call' for e in run.events
|
||||
) else "stop"
|
||||
finish_reason = "tool_calls" if state['tool_call'] else "stop"
|
||||
yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason))}\n\n"
|
||||
break
|
||||
|
||||
if delta:
|
||||
yield f"data: {json.dumps(format_chunk(agent_id, delta))}\n\n"
|
||||
else:
|
||||
raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}")
|
||||
|
||||
if delta:
|
||||
yield f"data: {json.dumps(format_chunk(agent_id, delta))}\n\n"
|
||||
|
||||
# Usage
|
||||
yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason, usage))}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
print("[openai] ERROR: " + str(e))
|
||||
print(traceback.format_exc())
|
||||
@@ -309,6 +337,8 @@ def register(server, app):
|
||||
while True:
|
||||
msg_type, data = await queue.get()
|
||||
if msg_type == 'eof':
|
||||
if state['error']:
|
||||
raise state['error']
|
||||
break
|
||||
elif msg_type == 'content':
|
||||
content += data
|
||||
@@ -327,6 +357,8 @@ def register(server, app):
|
||||
# Deferred calls stay opaque to the client: only the
|
||||
# call id and type go on the wire.
|
||||
tool_calls.append({"id": data.id, "type": "tool"})
|
||||
elif msg_type == 'usage':
|
||||
usage = data
|
||||
else:
|
||||
raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}")
|
||||
except Exception as e:
|
||||
@@ -346,6 +378,7 @@ def register(server, app):
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": agent_id,
|
||||
"usage": usage,
|
||||
"choices": [{
|
||||
"message": message,
|
||||
"finish_reason": finish_reason,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json, asyncio
|
||||
import json, asyncio, traceback
|
||||
from quart import websocket
|
||||
from utils import encode_json
|
||||
from stream import Stream # isinstance check in send-agent-prompt
|
||||
|
||||
def register(server, app, auth_manager):
|
||||
@server.websocket('/ws')
|
||||
@@ -40,10 +39,14 @@ def register(server, app, auth_manager):
|
||||
|
||||
send_queue = asyncio.Queue()
|
||||
async def sender_impl():
|
||||
# This thread MUST NOT crash
|
||||
nonlocal send_queue
|
||||
while True:
|
||||
try:
|
||||
msg = await send_queue.get()
|
||||
await websocket.send(encode_json(msg))
|
||||
except Exception as err:
|
||||
print(traceback.format_exc())
|
||||
async def send(msg):
|
||||
await send_queue.put(msg)
|
||||
|
||||
@@ -65,13 +68,54 @@ def register(server, app, auth_manager):
|
||||
stop_listening = app.on_event(on_event)
|
||||
|
||||
while True:
|
||||
req = json.loads(await websocket.receive())
|
||||
if not 'action' in req:
|
||||
asyncio.create_task(handle_request(
|
||||
app, user_id, session_connections, send, await websocket.receive()
|
||||
))
|
||||
|
||||
finally:
|
||||
print("User logged out:", user_id)
|
||||
sender_task.cancel()
|
||||
if ws_task in app.ws_tasks:
|
||||
app.ws_tasks.remove(ws_task)
|
||||
# Unregister the event listener: the send closure would
|
||||
# otherwise keep queueing events for a dead socket forever
|
||||
if stop_listening:
|
||||
stop_listening()
|
||||
# All remotes served by this session are disconnected with it.
|
||||
# disconnectRemote is idempotent, so remotes that were already
|
||||
# disconnected manually (possibly from another session) are safe.
|
||||
for remotename in list(session_connections):
|
||||
app.disconnectRemote(user_id, remotename)
|
||||
app.remove_user_connection(user_id, send_queue)
|
||||
|
||||
# Error handling
|
||||
async def handle_request(app, user_id, session_connections, send, req):
|
||||
try:
|
||||
req = json.loads(req)
|
||||
await process_request(app, user_id, session_connections, send, req)
|
||||
|
||||
except Exception as err:
|
||||
action = req.get('action') if isinstance(req, dict) else None
|
||||
nonce = req.get('nonce') if isinstance(req, dict) else None
|
||||
|
||||
# Maintainers
|
||||
print(f"[ws] req error @{user_id} {action!r}: {err!r}")
|
||||
print(traceback.format_exc())
|
||||
# Client — nonce makes this routable to the caller's promise/callback
|
||||
try:
|
||||
await send({'error': str(err), 'nonce': nonce})
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
# Actual processing
|
||||
async def process_request(app, user_id, session_connections, send, req):
|
||||
if 'action' not in req:
|
||||
await send({'error': 'no action'})
|
||||
continue
|
||||
return
|
||||
action = req['action']
|
||||
body = req.get('body', {})
|
||||
nonce = req.get('nonce', None)
|
||||
print(f"[ws] req @{user_id} {action}")
|
||||
|
||||
async def agent_id_from_body (allow_none=False):
|
||||
if 'agentId' in body:
|
||||
@@ -84,7 +128,7 @@ def register(server, app, auth_manager):
|
||||
raise Exception("field 'agentId' or 'agentname' required")
|
||||
|
||||
if action == "ping":
|
||||
await send({'message': "pong"})
|
||||
await send({'message': "pong", 'nonce': nonce})
|
||||
|
||||
elif action == "create-agent":
|
||||
name = body['name']
|
||||
@@ -150,6 +194,10 @@ def register(server, app, auth_manager):
|
||||
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
|
||||
await send({'message': "resource saved", 'nonce': nonce})
|
||||
|
||||
elif action == "delete-agent-resource":
|
||||
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
|
||||
await send({'message': "resource deleted", 'nonce': nonce})
|
||||
|
||||
elif action == "get-agent-runs":
|
||||
await send({
|
||||
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
|
||||
@@ -268,7 +316,7 @@ def register(server, app, auth_manager):
|
||||
elif action == "update-chat-message":
|
||||
message = await app.update_chat_message(
|
||||
message_id=body['messageId'],
|
||||
content=body['content'],
|
||||
content=body.get('content'),
|
||||
kind=body.get('kind'),
|
||||
subkind=body.get('subkind'),
|
||||
format=body.get('format'),
|
||||
@@ -289,23 +337,30 @@ def register(server, app, auth_manager):
|
||||
)
|
||||
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
|
||||
|
||||
elif action == "send-agent-prompt":
|
||||
elif action == "prompt-agent":
|
||||
if 'instruction' not in body and 'input' not in body:
|
||||
await send({'error': "instruction or input required"})
|
||||
continue
|
||||
await send({'error': "instruction or input required", 'nonce': nonce})
|
||||
return
|
||||
|
||||
response = await app.send_agent_prompt(
|
||||
# Multiple-result action, same shape as read-stream: every
|
||||
# generated message is its own packet carrying the request
|
||||
# nonce, so the client callback runs once per message. The
|
||||
# terminal eof packet resolves the action. Streams referenced
|
||||
# by open assistant/reasoning messages are read by the
|
||||
# client via the existing read-stream action.
|
||||
generator = app.prompt_agent(
|
||||
await agent_id_from_body(),
|
||||
body.get('instruction'),
|
||||
body.get('input'),
|
||||
body.get('options', {})
|
||||
)
|
||||
if isinstance(response, Stream):
|
||||
if body.get('stream'):
|
||||
response = {"streamId": response.id}
|
||||
else:
|
||||
response = await response.content_wait()
|
||||
await send({'message': "prompt sent", 'response': response, 'nonce': nonce})
|
||||
try:
|
||||
async for msg in generator:
|
||||
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
|
||||
finally:
|
||||
# Disconnect / cancellation point: closes the run task.
|
||||
await generator.aclose()
|
||||
await send({'eof': True, 'nonce': nonce})
|
||||
|
||||
elif action == "read-stream":
|
||||
stream_id = body['streamId']
|
||||
@@ -322,18 +377,3 @@ def register(server, app, auth_manager):
|
||||
|
||||
else:
|
||||
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
|
||||
finally:
|
||||
print("User logged out:", user_id)
|
||||
sender_task.cancel()
|
||||
if ws_task in app.ws_tasks:
|
||||
app.ws_tasks.remove(ws_task)
|
||||
# Unregister the event listener: the send closure would
|
||||
# otherwise keep queueing events for a dead socket forever
|
||||
if stop_listening:
|
||||
stop_listening()
|
||||
# All remotes served by this session are disconnected with it.
|
||||
# disconnectRemote is idempotent, so remotes that were already
|
||||
# disconnected manually (possibly from another session) are safe.
|
||||
for remotename in list(session_connections):
|
||||
app.disconnectRemote(user_id, remotename)
|
||||
app.remove_user_connection(user_id, send_queue)
|
||||
+3
-10
@@ -149,7 +149,7 @@ class Tool:
|
||||
|
||||
@classmethod
|
||||
def tool_name(cls):
|
||||
return ''.join(['_'+c.lower() if c.isupper() else c for c in cls.__name__]).lstrip('_')
|
||||
return ''.join(['-'+c.lower() if c.isupper() else c for c in cls.__name__]).lstrip('-')
|
||||
|
||||
|
||||
def _generate_spec(self):
|
||||
@@ -218,24 +218,17 @@ class ToolCall:
|
||||
is_error: Whether the result represents an error.
|
||||
"""
|
||||
|
||||
def __init__(self, tool_name, arguments={}, id=None):
|
||||
def __init__(self, tool_name, arguments={}, id=None, result=None):
|
||||
self.id = id or ""
|
||||
self.tool_name = tool_name
|
||||
self.arguments = arguments
|
||||
|
||||
self.result = None
|
||||
self.result = result
|
||||
|
||||
def __repr__(self):
|
||||
id_prefix = self.id + ', ' if self.id else ''
|
||||
return f"ToolCall({id_prefix}tool={self.tool_name!r}, args={self.arguments!r})"
|
||||
|
||||
def call_message(self):
|
||||
raise Exception("TODO: Llama message for tool call")
|
||||
|
||||
def result_message(self):
|
||||
from inference import LlamaMessage
|
||||
return LlamaMessage("tool", self.result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to format tool specs per engine
|
||||
|
||||
+30
-5
@@ -1,14 +1,39 @@
|
||||
import json
|
||||
|
||||
from stream import Stream
|
||||
from tool import ToolSpec
|
||||
from tool import ToolSpec, ToolCall
|
||||
from inference import Message
|
||||
|
||||
# TODO: Move to module 'format'
|
||||
def encode_value(value):
|
||||
if isinstance(value, ToolSpec):
|
||||
return value.to_openai_dict()
|
||||
elif isinstance(value, Message):
|
||||
return {'role': value.role, 'content': encode_value(value.content)}
|
||||
elif isinstance(value, ToolCall):
|
||||
json = {
|
||||
'id': value.id,
|
||||
'tool_name': value.tool_name,
|
||||
'arguments': encode_value(value.arguments),
|
||||
}
|
||||
if value.result != None:
|
||||
json['result'] = encode_value(value.result),
|
||||
return json
|
||||
elif isinstance(value, Stream):
|
||||
if value.open:
|
||||
return {'streamId': value.id}
|
||||
else:
|
||||
return value.content
|
||||
else:
|
||||
return value
|
||||
|
||||
def encode_json(value):
|
||||
def encode_unknown(value):
|
||||
if isinstance(value, ToolSpec):
|
||||
return value.to_openai_dict()
|
||||
elif isinstance(value, Stream):
|
||||
return {'streamId': value.id}
|
||||
# All complex values are transformed to a dict
|
||||
value = encode_value(value)
|
||||
# otherwise simply show the repr
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
else:
|
||||
return repr(value)
|
||||
return json.dumps(value, default=encode_unknown)
|
||||
+13
-11
@@ -1,27 +1,29 @@
|
||||
# Drives
|
||||
|
||||
Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Everything is addressed by *drive name + filename* — the random file id never appears in URLs. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links.
|
||||
Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Client-facing routes are addressed by *drive name + filename* — the random file id never appears in URLs — though several `drives.py` helpers below are id-addressed internally. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links.
|
||||
|
||||
## Logical model
|
||||
|
||||
- **Drive** — user-scoped, addressed by name. Created implicitly on first upload to a new name; there is no create/rename/delete-drive endpoint.
|
||||
- **File** — readable `filename`, mime type, size, `created`/`accessed` timestamps.
|
||||
- **Folders are virtual** — a filename may contain slashes (`docs/api/readme.md`); the Drives view splits names to render a tree, but storage stays flat.
|
||||
- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`) that doubles as the on-disk filename, so user-facing names never touch the filesystem.
|
||||
- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`, 16 hex chars) that doubles as the on-disk filename, so user-facing names never touch the filesystem.
|
||||
|
||||
## Backend interface
|
||||
|
||||
Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter.
|
||||
|
||||
Client-facing file routes are drive+filename-addressed, but the `drives.py` helpers behind them are mixed: `upload_file` accepts drive+filename directly, while `get_metadata(file_id)` and `delete_file(file_id)` are id-addressed, so their routes must first resolve filename → id (via the drive's file records) before calling them. The two listing routes are not implemented in `drives.py` at all — their logic lives elsewhere.
|
||||
|
||||
| Route | Behavior |
|
||||
| --- | --- |
|
||||
| `GET /files` | List the user's drives: `[{id, name}]` |
|
||||
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]` |
|
||||
| `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. |
|
||||
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]`. Not implemented in `drives.py`. |
|
||||
| `POST /files/<drive>/<filename>` | Multipart upload (field `file`) in a single request. The URL tail — `<path:filename>`, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns `{fileId}`. |
|
||||
| `GET /files/<drive>/<filename>` | Serve the bytes with their stored mime type, or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
|
||||
| `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns `{fileId}`. |
|
||||
| `GET /files/<drive>/<filename>` | Serve the bytes, with the mime type looked up from the file record (not from `resolve_file`), or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
|
||||
| `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns the removed absolute disk path (`abs_path`). |
|
||||
|
||||
Every resolve (download, metadata, delete) stamps `accessed_at`.
|
||||
Only `resolve_file` stamps `accessed_at`; `get_metadata` and `delete_file` never touch it.
|
||||
|
||||
## Frontend interface
|
||||
|
||||
@@ -29,20 +31,20 @@ In `frontend/src/backend.js`:
|
||||
|
||||
- `webFetch(path, options)` — generic REST helper: sets `Authorization: Bearer` (waiting for login if needed), parses JSON, throws on non-2xx. Downloads bypass it.
|
||||
- `getDrives()`, `getDriveFiles(driveName)`, `deleteDriveFile(driveName, filename)`
|
||||
- `uploadDriveFile(driveName, file)` — multipart POST of the whole file.
|
||||
- `uploadDriveFile(driveName, file, path)` — multipart-POSTs the whole file to `/files/<driveName>/<path>`, where `path` is the destination filename including any virtual folder prefix (`docs/api/readme.md`). The composite path belongs in the URL, since the upload route only exists at `/files/<drive>/<path:filename>`.
|
||||
- `driveFileUrl(driveName, filename)` — GET URL carrying `?token=`, used for download anchors and for opening a file in a tab.
|
||||
|
||||
`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker, and deletes with confirmation.
|
||||
`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker (building the target path from the picked folder and passing it as `uploadDriveFile`'s third argument), and deletes with confirmation.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id char(64) pk, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. Ownership rides the `User → Drive → File` chain and is checked before any disk access.
|
||||
- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. The `id` is a 16-char hex string, not `char(64)`. Ownership rides the `User → Drive → File` chain; it is checked in `resolve_file` before disk access, but `delete_file` and `get_metadata` take a bare `file_id` and perform no ownership check of their own — their routes must guard them.
|
||||
- Disk layout: `{data_dir}/drives/{drive_id}/{file_id}` — keyed by drive id, not by user or name. `init_drives(data_directory)`, called from `main()`, creates the root.
|
||||
- Upload streams to disk in 8 KiB chunks and only then writes the DB record; delete removes the record first, then the blob.
|
||||
|
||||
## Known issues
|
||||
|
||||
- `uploadDriveFile` POSTs to `/files/<drive>`, where no POST route exists, and drops the folder-path argument `Drives.vue` passes it — UI uploads fail, and folder-targeted upload is not wired up.
|
||||
- Drives are only created implicitly, and the view only uploads into existing drives, so the first drive cannot be created from the frontend.
|
||||
- Uploads are meant to refuse a filename already used in the drive, but the check queries the `Drive` model with `DriveFile`'s fields, so it errors instead of detecting duplicates.
|
||||
- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404.
|
||||
- `delete_file` and `get_metadata` take a bare `file_id` with no ownership check; if their routes don't resolve and authorize the file against the owning user, any authenticated user could delete or inspect another user's file by id.
|
||||
@@ -95,3 +95,13 @@ input:disabled, textarea:disabled, select:disabled {
|
||||
.with-sidebar-expanded .when-sidebar-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p, ul, il {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
@@ -379,6 +379,8 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
|
||||
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
|
||||
// so they authenticate with the bearer token in the Authorization header.
|
||||
|
||||
// TODO: Next time these functions need updating, move them directly to views/Drives.vue
|
||||
|
||||
export function getDrives () {
|
||||
return webFetch('/files');
|
||||
}
|
||||
@@ -387,10 +389,10 @@ export function getDriveFiles (driveName) {
|
||||
return webFetch(`/files/${driveName}`);
|
||||
}
|
||||
|
||||
export function uploadDriveFile (driveName, file) {
|
||||
export function uploadDriveFile (driveName, file, path) {
|
||||
const body = new FormData();
|
||||
body.append('file', file, file.name);
|
||||
return webFetch(`/files/${driveName}`, {
|
||||
return webFetch(`/files/${driveName}/${path}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -545,6 +545,33 @@ export class ChatSession {
|
||||
this.creatingThread.value = Boolean(fork);
|
||||
}
|
||||
|
||||
// Sets the render format of a message ("markdown", or "" for plain text).
|
||||
// Omit `content` so the backend leaves the existing content untouched.
|
||||
setMessageFormat (message, format) {
|
||||
backend.sendAction("update-chat-message", {
|
||||
messageId: message.id, format,
|
||||
}).then(response => {
|
||||
if (response && response.error) {
|
||||
ui.alert("Error updating message format: " + response.error);
|
||||
}
|
||||
});
|
||||
|
||||
// No update event is broadcast (matching the content-edit path above), so
|
||||
// patch the source message and rebuild the entries locally. Other sessions
|
||||
// and stale views won't reflect this until reload — which is correct, since
|
||||
// the format is persisted. If we ever broadcast edits, flip this to rely on
|
||||
// the incoming update instead.
|
||||
let thread = this.getThread(message.threadId);
|
||||
let existing = thread.messages.find(msg => msg.id == message.id);
|
||||
if (existing) {
|
||||
existing.format = format;
|
||||
} else {
|
||||
console.error("Can't set format locally?", {messageId: message.id, thread});
|
||||
}
|
||||
|
||||
this.computeChatEntries();
|
||||
}
|
||||
|
||||
// Adds a bulk of messages to the message list, in their correct
|
||||
// chronological position and replacing duplicates
|
||||
addMessageBulk (messages) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import * as ui from '../ui'
|
||||
|
||||
const props = defineProps({
|
||||
/** JSON schema object describing the input structure */
|
||||
@@ -55,6 +56,17 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function addField(field) {
|
||||
let key = await ui.input("Enter new property name");
|
||||
if (!key) return;
|
||||
|
||||
// Never mutate the field object in place.
|
||||
currentValue.value = {
|
||||
...(currentValue.value || {}),
|
||||
[key]: null
|
||||
};
|
||||
}
|
||||
|
||||
const computedFields = computed(() => {
|
||||
let fields = [];
|
||||
let skip = {};
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { marked } from "marked";
|
||||
import * as backend from '../backend';
|
||||
import * as iconoir from '@iconoir/vue';
|
||||
marked.use({
|
||||
renderer: {
|
||||
code (code, infostring, escaped) {
|
||||
let langElem = infostring ?
|
||||
`<div class="language">${infostring}</div>` : "";
|
||||
code ({lang, text}) {
|
||||
let langElem = lang ?
|
||||
`<div class="code-lang">${lang}</div>` : "";
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
const escaped = div.innerHTML;
|
||||
|
||||
return `<div class="code">
|
||||
${langElem}
|
||||
<div class="content">${code}</div>
|
||||
<div class="code-content">${escaped}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +34,12 @@ const props = defineProps({
|
||||
logExpanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
// Toggle set of expanded message ids, shared with the parent view.
|
||||
// Used by log-groups to expand/collapse the previewed log.
|
||||
expandedLogs: {
|
||||
type: Object,
|
||||
required: false,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,6 +52,8 @@ function chatEntryClass(msg) {
|
||||
return ['resource', 'resource-' + (msg.subkind || 'generic')];
|
||||
} else if (msg.type == 'log') {
|
||||
return ['log'];
|
||||
} else if (msg.type == 'log-group') {
|
||||
return ['log-group'];
|
||||
} else if (msg.type == 'thread-header') {
|
||||
let cls = ['thread'];
|
||||
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
|
||||
@@ -104,14 +117,110 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
|
||||
// === Log groups === //
|
||||
// A log-group collects consecutive log messages. Each one is shown as a square
|
||||
// icon (which selects it as the preview); the selected log is previewed below.
|
||||
|
||||
const logs = computed(() =>
|
||||
props.message.type == 'log-group' ? (props.message.logs || []) : []
|
||||
);
|
||||
|
||||
// Icon used for a log's square button, chosen by log subkind
|
||||
function logIcon(subkind) {
|
||||
if (subkind == 'reasoning') return iconoir.Brain;
|
||||
if (subkind == 'tool-call') return iconoir.Wrench;
|
||||
return iconoir.Terminal;
|
||||
}
|
||||
|
||||
// Null means "default to the last log in the group"
|
||||
const selectedLogId = ref(null);
|
||||
|
||||
const selectedLog = computed(() => {
|
||||
const arr = logs.value;
|
||||
if (!arr.length) return null;
|
||||
|
||||
if (selectedLogId.value != null) {
|
||||
const found = arr.find(l => l.id == selectedLogId.value);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return arr[arr.length - 1];
|
||||
});
|
||||
|
||||
// Streamed logs resolve their content through a shared per-stream cache, so
|
||||
// switching the preview between logs still shows up-to-date content.
|
||||
const boundStreams = new Set();
|
||||
const streamContents = ref({});
|
||||
|
||||
function bindLogStreams() {
|
||||
for (const log of logs.value) {
|
||||
if (log.format != 'stream') continue;
|
||||
|
||||
const streamId = parseInt(log.content);
|
||||
if (boundStreams.has(streamId)) continue;
|
||||
boundStreams.add(streamId);
|
||||
|
||||
const stream = backend.readStream(streamId);
|
||||
const update = () => {
|
||||
streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent };
|
||||
};
|
||||
update();
|
||||
stream.on('data', update);
|
||||
}
|
||||
}
|
||||
|
||||
watch(logs, bindLogStreams, { immediate: true });
|
||||
|
||||
const selectedLogText = computed(() => {
|
||||
const log = selectedLog.value;
|
||||
if (!log) return '';
|
||||
if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? '';
|
||||
return log.content ?? '';
|
||||
});
|
||||
|
||||
const groupLogLong = computed(() => {
|
||||
if (!selectedLog.value) return false;
|
||||
const lines = String(selectedLogText.value ?? '').split('\n');
|
||||
return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH);
|
||||
});
|
||||
|
||||
const groupExpanded = computed(() =>
|
||||
selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id)
|
||||
);
|
||||
|
||||
// Collapsed preview keeps the *last* N characters (the tail), not the head;
|
||||
// CSS then clips the top so only the bottom lines show.
|
||||
const groupLogContent = computed(() => {
|
||||
const text = String(selectedLogText.value ?? '');
|
||||
if (groupLogLong.value && !groupExpanded.value) {
|
||||
return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES));
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null);
|
||||
|
||||
function toggleGroupLog() {
|
||||
if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id);
|
||||
}
|
||||
|
||||
function onContextMenu() {
|
||||
if (props.message.type == 'log-group') {
|
||||
if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value);
|
||||
} else {
|
||||
emit('open-menu', props.message, logMeta.value);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)">
|
||||
<div :class="chatEntryClass(message)" @contextmenu.prevent="onContextMenu" @click="handleClick(message)">
|
||||
<template v-if="message.type == 'message' || message.type == null">
|
||||
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
|
||||
|
||||
<div v-if="format == 'markdown'" v-html="marked.parse(message.content)" class="content"></div>
|
||||
<div v-if="format == 'markdown'" v-html="marked.parse(message.content)" class="md-content" style="white-space: collapse;"></div>
|
||||
<div v-else class="content">{{content}}</div>
|
||||
</template>
|
||||
|
||||
@@ -131,6 +240,28 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
||||
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
|
||||
</template>
|
||||
|
||||
<template v-if="message.type == 'log-group'">
|
||||
<div class="log-icons">
|
||||
<button
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
type="button"
|
||||
class="icon"
|
||||
:class="{ highlight: selectedLog && log.id == selectedLog.id }"
|
||||
:title="(log.sender || '') + (log.subkind ? ' [' + log.subkind + ']' : '')"
|
||||
@click="selectedLogId = log.id"
|
||||
>
|
||||
<component :is="logIcon(log.subkind)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog" class="log-preview">
|
||||
<div class="log-sender">{{selectedLog.sender}}<span v-if="selectedLog.subkind"> [{{selectedLog.subkind}}]</span></div>
|
||||
<div class="content" :class="{ clipped: !groupExpanded }">{{groupLogContent}}</div>
|
||||
<a v-if="groupLogLong" class="log-toggle" @click.prevent="toggleGroupLog()">{{groupExpanded ? '(collapse)' : '(expand)'}}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="message.type == 'thread-header'">
|
||||
<div class="thread-title">
|
||||
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
|
||||
@@ -189,14 +320,6 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
||||
margin-right: 2em;
|
||||
}
|
||||
|
||||
.message .content {
|
||||
white-space: preserve;
|
||||
}
|
||||
|
||||
.message ul, .message ol {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message .sender,
|
||||
.note .sender {
|
||||
font-weight: bold;
|
||||
@@ -223,6 +346,67 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* === Log groups === */
|
||||
|
||||
/* Completely transparent container; stacks icons above the preview and
|
||||
stretches to the full width of the chat */
|
||||
.log-group {
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
padding: 0.6em 1.4em;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
/* Square buttons (sized by the global `button.icon`) that wrap to the width,
|
||||
each line centered within the container */
|
||||
.log-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.log-icons .highlight {
|
||||
color: var(--color-highlight);
|
||||
border-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.log-preview {
|
||||
font-family: monospace;
|
||||
font-size: 80%;
|
||||
max-width: 70em;
|
||||
}
|
||||
|
||||
.log-preview .log-sender {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.log-preview .content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: anywhere;
|
||||
width: fit-content;
|
||||
max-width: 70em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Clipping shows only the bottom lines: the content is bottom-aligned, so any
|
||||
vertical overflow is clipped off the top. ~6 lines at line-height 1.3. */
|
||||
.log-preview .content.clipped {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
max-height: 7.8em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-preview .log-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.resource {
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px dashed var(--color-border);
|
||||
@@ -288,4 +472,38 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.md-content {
|
||||
white-space: collapse;
|
||||
width: fit-content;
|
||||
max-width: 50em;
|
||||
line-height: normal;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Code blocks are generated at runtime, these styles cannot be scoped */
|
||||
|
||||
.code {
|
||||
background-color: var(--color-surface-input);
|
||||
border-radius: var(--border-radius);
|
||||
margin: .5em 0;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.code-lang {
|
||||
padding-bottom: .5em;
|
||||
margin-top: -.5em;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -23,8 +23,8 @@
|
||||
<style scoped>
|
||||
:slotted(h1) {
|
||||
font-size: 160%;
|
||||
font-weight: bold;
|
||||
padding: 0 0.4em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
|
||||
+136
-55
@@ -10,6 +10,8 @@
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
|
||||
import * as iconoir from '@iconoir/vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
@@ -32,8 +34,7 @@
|
||||
});
|
||||
|
||||
// UI State
|
||||
const addingTool = ref(null);
|
||||
const addingResource = ref(null);
|
||||
const targetResource = ref(null);
|
||||
|
||||
// === State Management === //
|
||||
|
||||
@@ -92,8 +93,7 @@
|
||||
|
||||
// Replace with a backend call
|
||||
backend.sendAction('get-agent-resources', {agentname}).then(res => {
|
||||
tools.value = res.resources.filter(r => r.kind == 'tool');
|
||||
resources.value = res.resources.filter(r => !['tool', 'config'].includes(r.kind));
|
||||
resources.value = res.resources;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,15 +157,36 @@
|
||||
}
|
||||
|
||||
promptHistory.value.push(historyEntry);
|
||||
let res = await backend.sendAction("send-agent-prompt", payload);
|
||||
|
||||
if (res.error) {
|
||||
historyEntry.error = res.error;
|
||||
// prompt-agent is a multiple-result action (like read-stream): one packet
|
||||
// per generated inference.Message, followed by a terminal eof packet. The
|
||||
// callback runs once per packet. Assistant/reasoning content is an open
|
||||
// stream ({streamId}); we read it live via read-stream.
|
||||
historyEntry.output = "";
|
||||
try {
|
||||
await backend.sendAction("prompt-agent", payload, (packet, deferred) => {
|
||||
if (packet.eof) {
|
||||
deferred.resolve(packet);
|
||||
} else {
|
||||
historyEntry.output = res.response;
|
||||
const msg = packet.data;
|
||||
if (msg.role !== 'assistant') return;
|
||||
const content = msg.content;
|
||||
if (content && content.streamId != null) {
|
||||
const stream = backend.readStream(content.streamId);
|
||||
stream.on('data', () => {
|
||||
historyEntry.output = stream.partialContent;
|
||||
});
|
||||
} else if (typeof content === 'string') {
|
||||
historyEntry.output += content;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
historyEntry.error = e.message || String(e);
|
||||
} finally {
|
||||
historyEntry.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function promptKeyDown (event, stage) {
|
||||
if (event.key == "Enter" && event.ctrlKey) {
|
||||
@@ -237,34 +258,66 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAddingTool() {
|
||||
let resource = {
|
||||
kind: 'tool',
|
||||
key: addingTool.value.name,
|
||||
value: JSON.parse(addingTool.value.value),
|
||||
};
|
||||
addingTool.value = null;
|
||||
async function updateResourceKey () {
|
||||
let newKey = await ui.input("Enter new resource key");
|
||||
if (!newKey || newKey == targetResource.value.key) return;
|
||||
|
||||
let res = await backend.sendAction("save-agent-resource", {
|
||||
agentId: agent.value.id,
|
||||
...resource
|
||||
});
|
||||
tools.value.push(resource);
|
||||
// Guard collisions, backend won't
|
||||
for (let r of resources.value) {
|
||||
if (r.kind == targetResource.value.kind && r.key == newKey) {
|
||||
ui.alert(`Conflict: ${targetResource.value.kind} '${newKey}'`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAddingResource() {
|
||||
let resource = {
|
||||
kind: addingResource.value.kind,
|
||||
key: addingResource.value.key,
|
||||
value: JSON.parse(addingResource.value.value),
|
||||
};
|
||||
addingResource.value = null;
|
||||
// Keys can't be updated directly, we move the resource
|
||||
await backend.sendAction("delete-agent-resource", {
|
||||
agentId: agent.value.id,
|
||||
...targetResource.value
|
||||
});
|
||||
|
||||
targetResource.value.key = newKey;
|
||||
await backend.sendAction("save-agent-resource", {
|
||||
agentId: agent.value.id,
|
||||
...targetResource.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveResource () {
|
||||
let res = await backend.sendAction("save-agent-resource", {
|
||||
agentId: agent.value.id,
|
||||
...resource
|
||||
...targetResource.value
|
||||
});
|
||||
resources.value.push(resource);
|
||||
|
||||
ui.alert(`Saved ${targetResource.value.kind} '${targetResource.value.key}'`);
|
||||
|
||||
if (targetResource.value.isNew) {
|
||||
delete targetResource.value.isNew;
|
||||
resources.value.push(targetResource.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource () {
|
||||
if (!await ui.confirm(`Delete ${targetResource.value.kind} '${targetResource.value.key}'?`)) return;
|
||||
|
||||
let res = await backend.sendAction("delete-agent-resource", {
|
||||
agentId: agent.value.id,
|
||||
...targetResource.value
|
||||
});
|
||||
|
||||
let index = resources.value.indexOf(targetResource.value);
|
||||
if (index >= 0) resources.value.splice(index, 1);
|
||||
targetResource.value = null;
|
||||
}
|
||||
|
||||
async function createResource () {
|
||||
let kind = await ui.input("Enter resource kind");
|
||||
if (!kind) return ui.alert('Cancel new resource');
|
||||
|
||||
let key = await ui.input("Enter resource key");
|
||||
if (!kind) return ui.alert('Cancel new resource');
|
||||
|
||||
targetResource.value = {kind, key, value: {}, isNew: true};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -327,36 +380,31 @@
|
||||
<JsonEditor :schema="config.schema" v-model="config.object" />
|
||||
<button type="submit" @click="saveConfig()">Save</button>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Tools</h3>
|
||||
<div v-for="(resource, index) in tools">
|
||||
<div class="label">{{ resource.key }}</div>
|
||||
<pre>{{ JSON.stringify(resource.value, null, 2) }}</pre>
|
||||
</div>
|
||||
<section v-if="addingTool">
|
||||
<input v-model="addingTool.name" placeholder="name"><br>
|
||||
<textarea v-model="addingTool.value" placeholder="{}"></textarea>
|
||||
<br>
|
||||
<button @click="saveAddingTool()">Save</button>
|
||||
<button @click="addingTool=null">Cancel</button>
|
||||
</section>
|
||||
<div v-else><button @click="addingTool={}">Add Tool</button></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Resources</h3>
|
||||
<div v-for="resource in resources">
|
||||
<div class="label">{{ resource.kind }}:{{ resource.key }}</div>
|
||||
<pre>{{ JSON.stringify(resource.value, null, 2) }}</pre>
|
||||
<div class="row">
|
||||
<section class="resource-detail column" v-if="targetResource">
|
||||
<div>
|
||||
<span class="resource-type">{{ targetResource.kind }}</span>
|
||||
{{ targetResource.key }}
|
||||
</div>
|
||||
<section v-if="addingResource">
|
||||
<input v-model="addingResource.kind" placeholder="kind/domain"><br>
|
||||
<input v-model="addingResource.key" placeholder="key"><br>
|
||||
<textarea v-model="addingResource.value" placeholder="value"></textarea>
|
||||
<br>
|
||||
<button @click="saveAddingResource()">Save</button>
|
||||
<button @click="addingResource=null">Cancel</button>
|
||||
<JsonEditor :schema="{type:'object'}" v-model="targetResource.value" />
|
||||
</section>
|
||||
<div v-else><button @click="addingResource={}">Add Resource</button></div>
|
||||
<section class="resource-table">
|
||||
<div class="resource-entry" v-for="resource in resources" @click="targetResource=resource">
|
||||
<span class="label">{{ resource.kind }}</span> {{ resource.key }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="targetResource">
|
||||
<button @click="updateResourceKey">Update Key</button>
|
||||
<button @click="deleteResource">Delete</button>
|
||||
<button @click="saveResource">Save</button>
|
||||
<button @click="targetResource=null">Cancel</button>
|
||||
</template>
|
||||
<button v-else @click="createResource">Add Resource</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -398,4 +446,37 @@
|
||||
resize: vertical;
|
||||
height: 5em;
|
||||
}
|
||||
|
||||
.resource-table {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(6em, 1fr));
|
||||
height: fit-content;
|
||||
flex: 1;
|
||||
|
||||
/*align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);*/
|
||||
}
|
||||
.resource-entry {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-surface-strong);
|
||||
padding: .4rem .6rem;
|
||||
width: max-content;
|
||||
}
|
||||
.resource-entry:hover {
|
||||
border-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.resource-type {
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: .1rem .3rem;
|
||||
}
|
||||
|
||||
/* style fix */
|
||||
.row > section { margin-left: 0; }
|
||||
.row > section:last-child { margin-right: 0; }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref, watch,
|
||||
ref, computed, watch,
|
||||
onMounted, onUnmounted,
|
||||
} from "vue";
|
||||
|
||||
@@ -30,6 +30,29 @@
|
||||
creatingThread,
|
||||
} = session;
|
||||
|
||||
// Consecutive log messages collapse into a single "log-group" entry, which
|
||||
// Message.vue renders as a container of icons plus a preview.
|
||||
const displayEntries = computed(() => {
|
||||
const entries = chatEntries.value || [];
|
||||
const out = [];
|
||||
let group = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type == 'log') {
|
||||
if (!group) {
|
||||
group = { id: 'log-group-' + entry.id, type: 'log-group', logs: [] };
|
||||
out.push(group);
|
||||
}
|
||||
group.logs.push(entry);
|
||||
} else {
|
||||
group = null;
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// UI state coupled to the document itself
|
||||
|
||||
// Message ids of logs currently expanded
|
||||
@@ -130,6 +153,12 @@
|
||||
{value: 'pin', title: "Pin message"},
|
||||
{value: 'edit', title: "Edit in-place"},
|
||||
{value: 'edit-fork', title: "Edit in new thread"},
|
||||
// ContextMenu.vue uses null for "dismissed", so the "None" entry can't
|
||||
// carry a null value; "format:" (empty suffix) is the sentinel for "".
|
||||
{title: "Set format", entries: [
|
||||
{value: 'format:markdown', title: "Markdown"},
|
||||
{value: 'format:', title: "None"},
|
||||
]},
|
||||
];
|
||||
|
||||
// Long logs can be expanded/collapsed
|
||||
@@ -164,6 +193,12 @@
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (selected?.startsWith('format:')) {
|
||||
session.setMessageFormat(message, selected.slice('format:'.length));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -204,7 +239,7 @@
|
||||
<div class="row" style="overflow: auto;">
|
||||
<div class="column">
|
||||
<div class="messages-container" ref="chatContainerElem">
|
||||
<div class="message-container" v-for="msg in chatEntries">
|
||||
<div class="message-container" v-for="msg in displayEntries">
|
||||
<Message
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
@@ -213,6 +248,7 @@
|
||||
|
||||
:log-expanded="expandedLogs.has(msg.id)"
|
||||
@toggle-log="expandedLogs.toggle(msg.id)"
|
||||
:expanded-logs="expandedLogs"
|
||||
|
||||
:cancellable="messageRunMap[msg.id]"
|
||||
@cancel="session.cancelMessageRun(msg.id)"
|
||||
|
||||
Reference in new issue
Block a user