feat: file upload retry, secure file tokens, UI polish

- Add HMAC-based file token auth for secure AI model file access
- Add file upload retry with exponential backoff (max 3 retries)
- File endpoint now requires session auth or signed token
- Support assistant role messages in chat input
- Optimistic UI for attachments on message send
- Verify topic ownership before allowing messages
- Switch web scraping to Firecrawl API
- Agent profile page layout fixes (proper flex overflow)
- Add quick switcher (Ctrl+K) to sidenav
- Clean up longcat.ts and stale comments
This commit is contained in:
Zoe
2026-06-06 00:16:39 -05:00
parent 47009b1f0a
commit 8ccaa824dd
20 changed files with 827 additions and 406 deletions
+37
View File
@@ -0,0 +1,37 @@
import { createHmac, timingSafeEqual } from 'crypto';
const DEFAULT_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
export function generateFileToken(
fileKey: string,
secret: string,
expiresInMs = DEFAULT_EXPIRY_MS,
): { exp: number; sig: string } {
const exp = Date.now() + expiresInMs;
const payload = `${fileKey}:${exp}`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
return { exp, sig };
}
export function verifyFileToken(
fileKey: string,
exp: number,
sig: string,
secret: string,
): boolean {
if (Date.now() > exp) {
return false;
}
const payload = `${fileKey}:${exp}`;
const expected = createHmac('sha256', secret).update(payload).digest('hex');
const sigBuffer = Buffer.from(sig, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(sigBuffer, expectedBuffer);
}
-116
View File
@@ -1,116 +0,0 @@
import { type ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
tools: TOOLS;
stopStream: () => void;
}) => TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>> {
let buffer = '';
let hasToolCallInStep = false;
let lastChunkId: string | undefined;
let lastChunkType: 'text' | 'reasoning' | undefined;
let step = 0;
return (_opts) => {
return new TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>>({
transform(chunk, controller) {
if (chunk.type === 'finish-step' || chunk.type === 'finish') {
step++;
if (hasToolCallInStep) {
// We clone the chunk and overwrite the finishReason.
// This tricks the SDK into thinking the model requested a tool natively.
const modifiedChunk = {
...chunk,
finishReason: 'tool-calls' as const,
};
// Reset for the next potential step
if (chunk.type === 'finish-step') {
hasToolCallInStep = false;
}
controller.enqueue(modifiedChunk);
return;
}
}
if (chunk.type === 'text-start' || chunk.type === 'reasoning-start') {
lastChunkId = chunk.id;
lastChunkType = chunk.type.split('-')[1] as 'text' | 'reasoning';
}
// We only care about text chunks
if (chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta') {
controller.enqueue(chunk);
return;
}
buffer += chunk.text;
// Check if we have a full tool call in the buffer
const pattern = /<longcat_tool_call>([\s\S]*?)<\/longcat_tool_call>/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(buffer)) !== null) {
console.log("longcat tool call found at index", match.index);
// 1. Enqueue any text that appeared BEFORE the tool call
const textBefore = buffer.substring(lastIndex, match.index);
if (textBefore) {
controller.enqueue({ type: chunk.type, text: textBefore, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
}
// 2. Parse the XML content
const content = match[1]!.trim();
const toolNameMatch = content.match(/^([^\s<]+)/);
if (toolNameMatch) {
hasToolCallInStep = true;
const toolName = toolNameMatch[1];
const args: Record<string, any> = {};
const argRegex = /<longcat_arg_key>(.*?)<\/longcat_arg_key>\s*<longcat_arg_value>(.*?)<\/longcat_arg_value>/gs;
let argMatch;
while ((argMatch = argRegex.exec(content)) !== null) {
args[argMatch[1]!.trim()] = argMatch[2]!.trim();
}
// 3. EMIT A TOOL CALL PART
// This is the "magic" - the SDK will see this and act as if the LLM
// called a native tool.
const toolCallId = `lc-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
controller.enqueue({
type: 'tool-call',
// @ts-ignore
id: toolCallId,
toolCallId,
toolName,
input: args,
dynamic: true,
});
}
lastIndex = pattern.lastIndex;
}
// Keep the remaining buffer (unclosed tags) for the next chunk
buffer = buffer.substring(lastIndex);
// If there's no open tag starting, we can flush the buffer as text
if (!buffer.includes('<longcat_tool_call>')) {
if (buffer) {
controller.enqueue({ type: chunk.type, text: buffer, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
buffer = '';
}
}
},
flush(controller) {
if (buffer && lastChunkId && lastChunkType) {
controller.enqueue({ type: `${lastChunkType}-delta`, text: buffer, id: lastChunkId });
}
}
});
};
}
+5 -1
View File
@@ -14,7 +14,11 @@ export default {
}
return Ok({
gateway: createOpenAI({ name: 'ClosedRouter', apiKey, baseURL }),
gateway: createOpenAI({
name: 'ClosedRouter',
apiKey,
baseURL
}),
streamTransformer: undefined,
textTransformer: undefined,
});