feat: plan 18
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Headers, Inject, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, Inject, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ok } from '../common/api-response.js';
|
||||
import { actorFromHeaders } from '../common/auth-context.js';
|
||||
import { ChatAskInput, ChatService } from './chat.service.js';
|
||||
@@ -12,6 +13,15 @@ export class ChatController {
|
||||
return ok(this.svc.ask(body, actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Post('ask/stream')
|
||||
askStream(
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Body() body: ChatAskInput,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
this.svc.streamAsk(body, actorFromHeaders(headers), res);
|
||||
}
|
||||
|
||||
@Get('audit/verify')
|
||||
verifyAudit() {
|
||||
return ok(this.svc.verifyAudit());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { APP_ROOT } from '../common/app-root.js';
|
||||
import type { SettingsActor } from '../settings/settings.service.js';
|
||||
|
||||
@@ -94,6 +95,49 @@ export class ChatService {
|
||||
return { ok: res.status === 0, output: res.stdout || res.stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 3: streaming read-only/analysis turns. The harness emits two NDJSON
|
||||
* phases — an UNCERTIFIED deterministic draft, then the certified final. We
|
||||
* only wrap the harness; RBAC/H4/router verdicts remain harness-owned. The
|
||||
* spawn is read-only (no side effect), so it is safe to stream the draft.
|
||||
*/
|
||||
streamAsk(input: ChatAskInput, actor: SettingsActor, res: Response) {
|
||||
if (!input.message || !input.message.trim()) {
|
||||
throw new ForbiddenException('CHAT_DENY message required');
|
||||
}
|
||||
this.requireRead(actor);
|
||||
const args = [
|
||||
CHAT_CLI,
|
||||
'ask',
|
||||
'--stream',
|
||||
'--message',
|
||||
input.message,
|
||||
'--actor',
|
||||
actor.actor,
|
||||
'--role',
|
||||
actor.role,
|
||||
'--project',
|
||||
actor.project,
|
||||
'--chat-id',
|
||||
input.chatId || 'chat-default',
|
||||
'--tenant',
|
||||
actor.tenant,
|
||||
];
|
||||
if (input.agentId) args.push('--agent', input.agentId);
|
||||
if (input.skillId) args.push('--skill', input.skillId);
|
||||
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
|
||||
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env } });
|
||||
child.stdout.on('data', (chunk) => res.write(chunk));
|
||||
child.on('error', () => {
|
||||
if (!res.headersSent) res.status(500);
|
||||
res.end();
|
||||
});
|
||||
child.on('close', () => res.end());
|
||||
}
|
||||
|
||||
replay(chatId = '', turnId = '', tenant = '') {
|
||||
const args = ['replay'];
|
||||
if (chatId) args.push('--chat-id', chatId);
|
||||
|
||||
@@ -139,9 +139,28 @@ export interface ChatAnswer {
|
||||
artifact_scan?: { ok: boolean; output: string };
|
||||
tool_output_scan?: { ok: boolean; output: string };
|
||||
};
|
||||
synthesis?: {
|
||||
mode: 'deterministic' | 'model' | string;
|
||||
reason?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
class?: string;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cost_source?: string;
|
||||
};
|
||||
actor: SettingsActor;
|
||||
}
|
||||
|
||||
export type ChatStreamPhase = Partial<ChatAnswer> & {
|
||||
phase?: 'draft' | 'final';
|
||||
answer: string;
|
||||
decision: string;
|
||||
certified: boolean;
|
||||
mode: string;
|
||||
sources: ChatSource[];
|
||||
};
|
||||
|
||||
export interface ChatAction {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -219,6 +238,44 @@ export const api = {
|
||||
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
|
||||
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
||||
askChatStream: async (
|
||||
actor: SettingsActor,
|
||||
body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number },
|
||||
onPhase: (phase: ChatStreamPhase) => void,
|
||||
): Promise<void> => {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
|
||||
const resp = await fetch(`${base}/chat/ask/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...actorHeaders(actor) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`stream failed: ${resp.status}`);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
const flush = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
onPhase(JSON.parse(trimmed) as ChatStreamPhase);
|
||||
} catch {
|
||||
/* ignore partial/non-JSON chunk */
|
||||
}
|
||||
};
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||||
flush(buf.slice(0, idx));
|
||||
buf = buf.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
flush(buf);
|
||||
},
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
@@ -10,6 +10,23 @@ function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
|
||||
return `${res.mode} / ${res.risk}`;
|
||||
}
|
||||
|
||||
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
|
||||
return {
|
||||
...(p as Partial<ChatAnswer>),
|
||||
success: p.decision === 'ANSWERED',
|
||||
mode: p.mode,
|
||||
risk: (p.risk as string) ?? 'low',
|
||||
decision: p.decision,
|
||||
answer: p.answer,
|
||||
sources: p.sources ?? [],
|
||||
certified: p.certified,
|
||||
audit: p.audit ?? {},
|
||||
audit_verify: p.audit_verify ?? { ok: true, output: '' },
|
||||
router: p.router ?? {},
|
||||
actor,
|
||||
} as ChatAnswer;
|
||||
}
|
||||
|
||||
function auditHash(res: ChatAnswer) {
|
||||
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
|
||||
}
|
||||
@@ -32,6 +49,9 @@ export function Chat() {
|
||||
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [draftText, setDraftText] = useState<string | null>(null);
|
||||
const [streamBusy, setStreamBusy] = useState(false);
|
||||
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['chat-audit'],
|
||||
@@ -72,6 +92,37 @@ export function Chat() {
|
||||
},
|
||||
});
|
||||
|
||||
const runStream = async () => {
|
||||
setStreamBusy(true);
|
||||
setError(null);
|
||||
setDraftText(null);
|
||||
try {
|
||||
await api.askChatStream(
|
||||
actor,
|
||||
{ message, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel },
|
||||
(phase: ChatStreamPhase) => {
|
||||
if (phase.phase === 'draft') {
|
||||
setDraftText(phase.answer);
|
||||
} else {
|
||||
setDraftText(null);
|
||||
setLast(finalToAnswer(phase, actor));
|
||||
}
|
||||
},
|
||||
);
|
||||
void auditQuery.refetch();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Stream failed');
|
||||
} finally {
|
||||
setStreamBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAsk = () => {
|
||||
if (streaming) void runStream();
|
||||
else ask.mutate({});
|
||||
};
|
||||
const busy = ask.isPending || streamBusy;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -92,13 +143,26 @@ export function Chat() {
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:bg-gray-300"
|
||||
disabled={!message.trim() || ask.isPending}
|
||||
onClick={() => ask.mutate({})}
|
||||
disabled={!message.trim() || busy}
|
||||
onClick={onAsk}
|
||||
>
|
||||
{ask.isPending ? 'Asking...' : 'Ask'}
|
||||
{busy ? 'Asking...' : 'Ask'}
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={streaming} onChange={(e) => setStreaming(e.target.checked)} />
|
||||
Stream
|
||||
</label>
|
||||
<StatusBadge value="read-only" />
|
||||
</div>
|
||||
{draftText && (
|
||||
<div className="rounded border border-orange-200 bg-orange-50 p-3 text-sm text-gray-700">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusBadge value="draft" />
|
||||
<span className="text-xs text-orange-700">UNCERTIFIED — awaiting H4/certify</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap leading-6">{draftText}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -171,6 +235,11 @@ export function Chat() {
|
||||
<StatusBadge value={last.mode} />
|
||||
<StatusBadge value={last.risk} />
|
||||
<StatusBadge value={last.decision} />
|
||||
{last.synthesis && (
|
||||
<StatusBadge value={last.synthesis.mode === 'model'
|
||||
? `model: ${last.synthesis.provider ?? 'provider'}`
|
||||
: 'deterministic'} />
|
||||
)}
|
||||
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
|
||||
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
|
||||
{last.loop_run && <StatusBadge value={last.loop_run.draft_certified ? 'loop certified' : 'loop held'} />}
|
||||
|
||||
Reference in New Issue
Block a user