chore: checkpoint browser tooling and routing updates
This commit is contained in:
@@ -13,6 +13,9 @@ import type { ModelConfig, ModelProvider } from '../../../config/schema.js';
|
||||
import { MODEL_PROVIDERS } from '../../../config/schema.js';
|
||||
import { createClientFromConfig } from '../../../daemon/index.js';
|
||||
import { estimateMessageTokens, getContextWindow } from '../../../context/tokens.js';
|
||||
import type { PairingManager } from '../../../channels/pairing.js';
|
||||
import { loginGitHub, loginOpenAI } from '../../../auth/index.js';
|
||||
import { OllamaClient, LlamaCppClient } from '../../../models/index.js';
|
||||
|
||||
/** Format a tool name like "gmail.list" -> "Gmail: List" */
|
||||
function formatToolName(name: string): string {
|
||||
@@ -49,6 +52,9 @@ export interface AppProps {
|
||||
model: string;
|
||||
agent?: NativeAgent;
|
||||
hookEngine?: HookEngine;
|
||||
pairingManager?: PairingManager;
|
||||
localProviders?: Record<string, ModelConfig>;
|
||||
currentLocalProvider?: string;
|
||||
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
|
||||
contextThresholdPct?: number;
|
||||
onTransfer?: (target: string) => string | void;
|
||||
@@ -63,6 +69,9 @@ export function App({
|
||||
model,
|
||||
agent,
|
||||
hookEngine,
|
||||
pairingManager,
|
||||
localProviders,
|
||||
currentLocalProvider,
|
||||
modelProviderConfigs,
|
||||
contextThresholdPct,
|
||||
onTransfer,
|
||||
@@ -196,6 +205,55 @@ export function App({
|
||||
}
|
||||
});
|
||||
|
||||
const pushAssistantMessage = useCallback((content: string) => {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
|
||||
}, [session]);
|
||||
|
||||
const getAvailableBackends = useCallback((): string[] => {
|
||||
const backends: string[] = [];
|
||||
if (currentLocalProvider) {
|
||||
backends.push(currentLocalProvider);
|
||||
}
|
||||
if (localProviders) {
|
||||
backends.push(...Object.keys(localProviders));
|
||||
}
|
||||
return [...new Set(backends)];
|
||||
}, [currentLocalProvider, localProviders]);
|
||||
|
||||
const createLocalClient = useCallback((cfg: ModelConfig): ModelClient | null => {
|
||||
if (cfg.provider === 'ollama') {
|
||||
return new OllamaClient({
|
||||
model: cfg.model,
|
||||
host: cfg.endpoint,
|
||||
});
|
||||
}
|
||||
if (cfg.provider === 'llamacpp') {
|
||||
return new LlamaCppClient({
|
||||
endpoint: cfg.endpoint ?? 'http://localhost:8080',
|
||||
model: cfg.model,
|
||||
authToken: cfg.auth_token,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const parseDurationToMs = useCallback((value: string): number | null => {
|
||||
const m = value.match(/^(\d+)([smhd])$/i);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseInt(m[1], 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return null;
|
||||
}
|
||||
const unit = m[2].toLowerCase();
|
||||
if (unit === 's') {return n * 1000;}
|
||||
if (unit === 'm') {return n * 60_000;}
|
||||
if (unit === 'h') {return n * 3_600_000;}
|
||||
if (unit === 'd') {return n * 86_400_000;}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (value: string) => {
|
||||
if (confirmation) {
|
||||
return;
|
||||
@@ -273,7 +331,12 @@ export function App({
|
||||
case 'verbose': {
|
||||
const next = !verbose;
|
||||
setVerbose(next);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Verbose mode: ${next ? 'on' : 'off'}` })]);
|
||||
let content = `Verbose mode: ${next ? 'on' : 'off'}`;
|
||||
if (next && agent) {
|
||||
const snapshot = agent.getToolInventorySnapshot();
|
||||
content += `\n[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`;
|
||||
}
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -480,12 +543,213 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
case 'backend':
|
||||
case 'login':
|
||||
case 'pair':
|
||||
case 'elevate':
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `/${command.type} is not supported in fullscreen mode.` })]);
|
||||
case 'backend': {
|
||||
if (!modelRouter) {
|
||||
pushAssistantMessage('Backend switching not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command.provider) {
|
||||
const current = modelRouter.getLocalProviderName() ?? currentLocalProvider ?? 'unknown';
|
||||
const available = getAvailableBackends();
|
||||
pushAssistantMessage(`Current local backend: ${current}\nAvailable: ${available.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerConfig = localProviders?.[command.provider];
|
||||
if (!providerConfig) {
|
||||
const available = getAvailableBackends();
|
||||
pushAssistantMessage(`Backend '${command.provider}' not configured.\nAvailable: ${available.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createLocalClient(providerConfig);
|
||||
if (!client) {
|
||||
pushAssistantMessage(`Unsupported backend provider '${providerConfig.provider}'.`);
|
||||
return;
|
||||
}
|
||||
|
||||
modelRouter.setLocalClient(client, command.provider);
|
||||
modelRouter.setTier('local');
|
||||
if (agent) {
|
||||
agent.setModelTier('local');
|
||||
}
|
||||
setCurrentModel(modelRouter.getLabel('local'));
|
||||
pushAssistantMessage(`Switched backend to ${command.provider}`);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'login': {
|
||||
const provider = (command.provider ?? '').trim().toLowerCase();
|
||||
if (!provider) {
|
||||
pushAssistantMessage('Usage: /login <provider>\nSupported: github, openai, anthropic, zai');
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'github') {
|
||||
pushAssistantMessage('Starting GitHub OAuth device login...');
|
||||
try {
|
||||
await loginGitHub((userCode, verificationUri) => {
|
||||
pushAssistantMessage(`GitHub login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
|
||||
});
|
||||
pushAssistantMessage('GitHub login complete. Token stored in ~/.config/flynn/auth.json');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
pushAssistantMessage(`GitHub login failed: ${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'openai') {
|
||||
pushAssistantMessage('Starting OpenAI OAuth device login...');
|
||||
try {
|
||||
await loginOpenAI((userCode, verificationUri) => {
|
||||
pushAssistantMessage(`OpenAI login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
|
||||
});
|
||||
pushAssistantMessage('OpenAI login complete. Credentials stored in ~/.config/flynn/auth.json');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
pushAssistantMessage(`OpenAI login failed: ${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'anthropic' || provider === 'zai' || provider === 'zhipuai') {
|
||||
pushAssistantMessage(
|
||||
`/${command.type} ${provider} requires key entry, which fullscreen mode does not mask.\nUse minimal mode (pnpm tui) for interactive key setup.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pushAssistantMessage(`Unknown login provider: ${provider}. Supported: github, openai, anthropic, zai`);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'pair': {
|
||||
if (!pairingManager) {
|
||||
pushAssistantMessage('Pairing not enabled. Set pairing.enabled: true in config.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.action === 'generate') {
|
||||
const code = pairingManager.generateCode(command.args);
|
||||
const pending = pairingManager.listPendingCodes().find(p => p.code === code);
|
||||
const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?';
|
||||
pushAssistantMessage(`Pairing code: ${code}\nExpires in ${expiresIn}s${command.args ? ` (label: ${command.args})` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.action === 'revoke') {
|
||||
const args = (command.args ?? '').trim();
|
||||
const parts = args.split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
pushAssistantMessage('Usage: /pair revoke <channel> <senderId>');
|
||||
return;
|
||||
}
|
||||
const [channel, senderId] = parts;
|
||||
const revoked = pairingManager.revokeApproval(channel, senderId);
|
||||
pushAssistantMessage(revoked ? `Revoked approval for ${channel}:${senderId}` : `No approval found for ${channel}:${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pairingManager.listPendingCodes();
|
||||
const approved = pairingManager.listApproved();
|
||||
if (pending.length === 0 && approved.length === 0) {
|
||||
pushAssistantMessage('No pending codes or approved senders.');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (pending.length > 0) {
|
||||
lines.push('Pending codes:');
|
||||
for (const p of pending) {
|
||||
const ttl = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000));
|
||||
lines.push(` ${p.code} expires in ${ttl}s${p.label ? ` (label: ${p.label})` : ''}`);
|
||||
}
|
||||
}
|
||||
if (approved.length > 0) {
|
||||
lines.push('Approved senders:');
|
||||
for (const a of approved) {
|
||||
const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' ');
|
||||
lines.push(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`);
|
||||
}
|
||||
}
|
||||
pushAssistantMessage(lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
case 'elevate': {
|
||||
const untilRaw = session.getConfig('elevation.until_ms');
|
||||
const reason = session.getConfig('elevation.reason') ?? '';
|
||||
const id = session.getConfig('elevation.id') ?? '';
|
||||
const showStatus = () => {
|
||||
if (!untilRaw || !id) {
|
||||
pushAssistantMessage('Elevated mode: off');
|
||||
return;
|
||||
}
|
||||
const untilMs = Number.parseInt(untilRaw, 10);
|
||||
if (!Number.isFinite(untilMs) || untilMs <= Date.now()) {
|
||||
session.deleteConfig('elevation.until_ms');
|
||||
session.deleteConfig('elevation.reason');
|
||||
session.deleteConfig('elevation.id');
|
||||
pushAssistantMessage('Elevated mode: off');
|
||||
return;
|
||||
}
|
||||
const remainingSec = Math.ceil((untilMs - Date.now()) / 1000);
|
||||
pushAssistantMessage(`Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}`);
|
||||
};
|
||||
|
||||
const raw = (command.args ?? '').trim();
|
||||
if (!raw) {
|
||||
showStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = raw.split(/\s+/);
|
||||
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
|
||||
const filtered = parts.filter((p) => p !== '--yes' && p !== '--confirm');
|
||||
|
||||
if (filtered.length === 0) {
|
||||
pushAssistantMessage('Usage: /elevate <duration> <reason...> --yes | /elevate off --yes');
|
||||
return;
|
||||
}
|
||||
|
||||
if (filtered[0] === 'off') {
|
||||
if (!hasYes) {
|
||||
pushAssistantMessage('Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes');
|
||||
return;
|
||||
}
|
||||
session.deleteConfig('elevation.until_ms');
|
||||
session.deleteConfig('elevation.reason');
|
||||
session.deleteConfig('elevation.id');
|
||||
pushAssistantMessage('Elevated mode: off');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasYes) {
|
||||
pushAssistantMessage('Refusing to enable elevation without explicit confirmation. Use: /elevate <duration> <reason...> --yes');
|
||||
return;
|
||||
}
|
||||
|
||||
const ttlMs = parseDurationToMs(filtered[0]);
|
||||
if (!ttlMs) {
|
||||
pushAssistantMessage('Invalid duration. Use one of: 30s, 10m, 1h, 1d');
|
||||
return;
|
||||
}
|
||||
|
||||
const reasonText = filtered.slice(1).join(' ').trim();
|
||||
const untilMs = Date.now() + ttlMs;
|
||||
const newId = `${untilMs}`;
|
||||
session.setConfig('elevation.until_ms', String(untilMs));
|
||||
session.setConfig('elevation.id', newId);
|
||||
if (reasonText) {
|
||||
session.setConfig('elevation.reason', reasonText);
|
||||
} else {
|
||||
session.deleteConfig('elevation.reason');
|
||||
}
|
||||
pushAssistantMessage(`Elevated mode: on until ${new Date(untilMs).toISOString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'message':
|
||||
break;
|
||||
@@ -585,6 +849,13 @@ export function App({
|
||||
messages.length,
|
||||
tokenUsage.inputTokens,
|
||||
tokenUsage.outputTokens,
|
||||
pushAssistantMessage,
|
||||
getAvailableBackends,
|
||||
createLocalClient,
|
||||
parseDurationToMs,
|
||||
localProviders,
|
||||
currentLocalProvider,
|
||||
pairingManager,
|
||||
modelProviderConfigs,
|
||||
onTransfer,
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ModelRouter } from '../../models/router.js';
|
||||
import type { NativeAgent } from '../../backends/native/agent.js';
|
||||
import type { HookEngine } from '../../hooks/index.js';
|
||||
import type { ModelConfig, ModelProvider } from '../../config/index.js';
|
||||
import type { PairingManager } from '../../channels/pairing.js';
|
||||
|
||||
export interface FullscreenTuiConfig {
|
||||
session: ManagedSession;
|
||||
@@ -16,6 +17,9 @@ export interface FullscreenTuiConfig {
|
||||
model: string;
|
||||
agent?: NativeAgent;
|
||||
hookEngine?: HookEngine;
|
||||
pairingManager?: PairingManager;
|
||||
localProviders?: Record<string, ModelConfig>;
|
||||
currentLocalProvider?: string;
|
||||
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
|
||||
contextThresholdPct?: number;
|
||||
onTransfer?: (target: string) => string | void;
|
||||
@@ -41,6 +45,9 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
|
||||
model: config.model,
|
||||
agent: config.agent,
|
||||
hookEngine: config.hookEngine,
|
||||
pairingManager: config.pairingManager,
|
||||
localProviders: config.localProviders,
|
||||
currentLocalProvider: config.currentLocalProvider,
|
||||
modelProviderConfigs: config.modelProviderConfigs,
|
||||
contextThresholdPct: config.contextThresholdPct,
|
||||
onTransfer: config.onTransfer,
|
||||
|
||||
@@ -468,7 +468,14 @@ export class MinimalTui {
|
||||
|
||||
private handleVerboseCommand(): void {
|
||||
this.verbose = !this.verbose;
|
||||
console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}\n`);
|
||||
console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}`);
|
||||
if (this.verbose && this.config.agent) {
|
||||
const snapshot = this.config.agent.getToolInventorySnapshot();
|
||||
console.log(
|
||||
`[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`,
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
|
||||
|
||||
Reference in New Issue
Block a user