fix: 修正 logo 中 N 和 G 字母造型

N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█),
避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
airlongdian
2026-06-14 09:48:03 +08:00
commit 9ea05df273
5757 changed files with 1170016 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, testLayer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, testLayer), opts)
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, liveLayer), opts)
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, liveLayer), opts)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts)
return { effect, live }
}
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const liveEnv = TestConsole.layer
export const it = make(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))

View File

@@ -0,0 +1,98 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import type { Service as LLMClientService } from "../../src/route/client"
import type { Service as RequestExecutorService } from "../../src/route/executor"
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket"
export type HandlerInput = {
readonly request: HttpClientRequest.HttpClientRequest
readonly text: string
readonly respond: (
body: ConstructorParameters<typeof Response>[0],
init?: ResponseInit,
) => HttpClientResponse.HttpClientResponse
}
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
const text = yield* Effect.promise(() => web.text())
return yield* handler({
request,
text,
respond: (body, init) => HttpClientResponse.fromWeb(request, new Response(body, init)),
})
}),
),
)
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
return Layer.mergeAll(deps, llmClientLayer)
}
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
/**
* Layer that returns a single fixed response body. Use for stream-parser
* fixture tests where the request shape is irrelevant. The body type widens
* to whatever `Response` accepts so binary fixtures (`Uint8Array`,
* `ReadableStream`, etc.) flow through without casts.
*/
export const fixedResponse = (
body: ConstructorParameters<typeof Response>[0],
init: ResponseInit = { headers: SSE_HEADERS },
) => runtimeLayer(handlerLayer((input) => Effect.succeed(input.respond(body, init))))
/**
* Layer that builds a response per request. Useful for echo servers.
*/
export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(handler))
/**
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
},
})
return input.respond(stream, { headers: SSE_HEADERS })
}),
)
/**
* Layer that returns successive bodies on each request. Useful for scripting
* multi-step model exchanges (e.g. tool-call loops). The last body in the
* array is reused if the test makes more requests than scripted.
*/
export const scriptedResponses = (bodies: ReadonlyArray<string>, init: ResponseInit = { headers: SSE_HEADERS }) => {
if (bodies.length === 0) throw new Error("scriptedResponses requires at least one body")
return Layer.unwrap(
Effect.gen(function* () {
const cursor = yield* Ref.make(0)
return dynamicResponse((input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
return input.respond(bodies[index] ?? bodies[bodies.length - 1], init)
}),
)
}),
)
}

View File

@@ -0,0 +1,27 @@
/**
* Shared chunk shapes for OpenAI Chat / OpenAI-compatible Chat fixture tests.
* Multiple test files build the same `{ id, choices: [{ delta, finish_reason }], usage }`
* envelope; consolidating here keeps tool-call event shapes consistent.
*/
const FIXTURE_ID = "chatcmpl_fixture"
export const deltaChunk = (delta: object, finishReason: string | null = null) => ({
id: FIXTURE_ID,
choices: [{ delta, finish_reason: finishReason }],
usage: null,
})
export const usageChunk = (usage: object) => ({
id: FIXTURE_ID,
choices: [],
usage,
})
export const finishChunk = (reason: string) => deltaChunk({}, reason)
export const toolCallChunk = (id: string, name: string, args: string, index = 0) =>
deltaChunk({
role: "assistant",
tool_calls: [{ index, id, function: { name, arguments: args } }],
})

View File

@@ -0,0 +1,17 @@
/**
* Helpers for building deterministic SSE bodies in tests.
*
* Inline template-literal SSE strings are hard to write and review when chunks
* contain JSON; this helper accepts plain values and serializes them, so test
* authors only think about the chunk shapes, not the wire format.
*/
export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
`${chunks.map(formatChunk).join("")}data: [DONE]\n\n`
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
/**
* Build an SSE body from already-serialized strings (used when the chunk shape
* itself is part of what's being tested, e.g. malformed chunks).
*/
export const sseRaw = (...lines: ReadonlyArray<string>): string => lines.map((line) => `${line}\n\n`).join("")

View File

@@ -0,0 +1,146 @@
import { Effect, Stream } from "effect"
import { LLMClient } from "../../src/route"
import {
LLMEvent,
LLMRequest,
Message,
type ContentPart,
type ProviderMetadata,
type ToolCallPart,
ToolResultPart,
type ToolResultValue,
type Usage,
} from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool"
import { ToolRuntime } from "../../src/tool-runtime"
interface RunOptions<T extends Tools> {
readonly request: LLMRequest
readonly tools: T
readonly maxSteps?: number
}
/** Test-owned continuation loop. Production callers must own durable history. */
export const runTools = <T extends Tools>(options: RunOptions<T>) =>
Stream.unwrap(
Effect.gen(function* () {
const names = new Set(Object.keys(options.tools))
let request = LLMRequest.update(options.request, {
tools: [...options.request.tools.filter((tool) => !names.has(tool.name)), ...toDefinitions(options.tools)],
})
let usage: Usage | undefined
const events: LLMEvent[] = []
for (let step = 0; step < (options.maxSteps ?? 10); step++) {
const streamed = Array.from(yield* LLMClient.stream(request).pipe(Stream.runCollect))
const state = stepState(streamed)
usage = addUsage(usage, state.usage)
events.push(...streamed.filter((event) => event.type !== "finish").map((event) => indexStep(event, step)))
if (state.toolCalls.length === 0) {
events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
return Stream.fromIterable(events)
}
const dispatched = yield* Effect.forEach(
state.toolCalls,
(call) => ToolRuntime.dispatch(options.tools, call).pipe(Effect.map((result) => [call, result] as const)),
{ concurrency: 10 },
)
events.push(...dispatched.flatMap(([, result]) => result.events))
if (step + 1 >= (options.maxSteps ?? 10)) {
events.push(LLMEvent.finish({ reason: state.reason, usage, providerMetadata: state.providerMetadata }))
return Stream.fromIterable(events)
}
request = LLMRequest.update(request, {
messages: [
...request.messages,
Message.assistant(state.assistantContent),
...dispatched.map(([call, dispatched]) =>
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
),
],
})
}
return Stream.fromIterable(events)
}),
)
const indexStep = (event: LLMEvent, index: number): LLMEvent => {
if (event.type === "step-start") return LLMEvent.stepStart({ index })
if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index })
return event
}
const stepState = (events: ReadonlyArray<LLMEvent>) => {
const assistantContent: ContentPart[] = []
const toolCalls: ToolCallPart[] = []
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
let usage: Usage | undefined
let providerMetadata: ProviderMetadata | undefined
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
} else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
} else if (event.type === "tool-call") {
assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event)
} else if (event.type === "tool-result" && event.providerExecuted && event.result !== undefined) {
assistantContent.push(
ToolResultPart.make({
id: event.id,
name: event.name,
result: event.result,
providerExecuted: true,
providerMetadata: event.providerMetadata,
}),
)
} else if (event.type === "finish") {
reason = event.reason
usage = event.usage
providerMetadata = event.providerMetadata
}
}
return { assistantContent, toolCalls, reason, usage, providerMetadata }
}
const appendText = (
content: ContentPart[],
type: "text" | "reasoning",
text: string,
providerMetadata?: ProviderMetadata,
) => {
const last = content.at(-1)
if (last?.type === type) {
content[content.length - 1] = {
...last,
text: `${last.text}${text}`,
providerMetadata: providerMetadata ?? last.providerMetadata,
}
return
}
content.push({ type, text, providerMetadata })
}
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
if (!left) return right
if (!right) return left
const sum = (key: keyof Usage) =>
typeof left[key] !== "number" && typeof right[key] !== "number"
? undefined
: ((left[key] as number | undefined) ?? 0) + ((right[key] as number | undefined) ?? 0)
return {
inputTokens: sum("inputTokens"),
outputTokens: sum("outputTokens"),
nonCachedInputTokens: sum("nonCachedInputTokens"),
cacheReadInputTokens: sum("cacheReadInputTokens"),
cacheWriteInputTokens: sum("cacheWriteInputTokens"),
reasoningTokens: sum("reasoningTokens"),
totalTokens: sum("totalTokens"),
} as Usage
}